From 646c7e258e2558aeccd93e22635b6abcf4cf2c69 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Fri, 13 Nov 2026 11:50:36 +0000 Subject: [PATCH 1/7] fix(novelty): the dpnp heading-query 0.0 is a template artifact, not a finding (#679) The novelty docs, the `--competitors` help and the `note` of every lexical routing result said every `dpnp-*` skill scored 0.0 recall@1 against its siblings. That figure came from queries drawn from the skills' section headings, which are one template across the family, so it measured the template, not the descriptions. The four sites now say that sibling skills built from one template have identical heading queries, so heading-query recall at the family level measures the template, and that sibling confusion is still a failure mode to check with independently written queries. `novelty routing --all-vs-all` now warns on stderr when half or more of the queries in `--queries-by-skill` are listed for more than one skill, since every skill then gets the same rank. Signed-off-by: Anton Volkov --- .../20261113_150000_heading-query-artifact.md | 3 + dokimi/docs/skill-novelty-walkthrough.md | 5 +- dokimi/docs/skill-novelty.md | 5 +- dokimi/dokimi/commands/novelty.py | 6 +- .../dokimi/commands/novelty_routing_report.py | 9 +++ dokimi/dokimi/novelty/query_overlap.py | 37 ++++++++++++ dokimi/dokimi/novelty/routing.py | 10 ++-- .../test_novelty_routing_shared_queries.py | 58 +++++++++++++++++++ 8 files changed, 121 insertions(+), 12 deletions(-) create mode 100644 dokimi/changelog.d/20261113_150000_heading-query-artifact.md create mode 100644 dokimi/dokimi/novelty/query_overlap.py create mode 100644 dokimi/tests/test_novelty_routing_shared_queries.py diff --git a/dokimi/changelog.d/20261113_150000_heading-query-artifact.md b/dokimi/changelog.d/20261113_150000_heading-query-artifact.md new file mode 100644 index 000000000..502d9ea80 --- /dev/null +++ b/dokimi/changelog.d/20261113_150000_heading-query-artifact.md @@ -0,0 +1,3 @@ +### Fixed + +- **The dpnp 0.0 recall@1 is no longer presented as a finding** — the novelty docs, the `--competitors` help and the `note` of every lexical routing result said every `dpnp-*` skill scored 0.0 against its siblings. That figure came from queries drawn from the skills' section headings, which are one template across the family, so it measured the template, not the descriptions. They now say so and point to queries written independently of the skills. `skill novelty routing --all-vs-all` warns on stderr when half or more of the queries in `--queries-by-skill` are listed for more than one skill, since every skill then gets the same rank. diff --git a/dokimi/docs/skill-novelty-walkthrough.md b/dokimi/docs/skill-novelty-walkthrough.md index 52efdaed0..7bce0ff80 100644 --- a/dokimi/docs/skill-novelty-walkthrough.md +++ b/dokimi/docs/skill-novelty-walkthrough.md @@ -46,8 +46,9 @@ such as `.git` are left out, and a byte-identical copy under another path counts `--competitors` adds or names them one by one. With no real siblings at hand, skip this step and say so; a field of competitors made up for the run measures nothing. -Read `recall@1` against the competitors, not alone. Measured on the study corpus, every -`dpnp-*` skill scored 0.0 against its siblings, and `linux-perf` scored 0.0 recall@1 on +Read `recall@1` against the competitors, not alone. Sibling skills built from one template +have identical heading queries, so heading-query recall@1 across such a family measures the +template, not the descriptions. Measured on the study corpus, `linux-perf` scored 0.0 recall@1 on five independently written requests — losing three of them to an `xpu-*` skill phrased the way a user phrases the problem. If that is your result, **fix the description before anything else**; `skill novelty analyze` holds the frontmatter fixed and will not see this. diff --git a/dokimi/docs/skill-novelty.md b/dokimi/docs/skill-novelty.md index 045a114eb..8d1b9d5fd 100644 --- a/dokimi/docs/skill-novelty.md +++ b/dokimi/docs/skill-novelty.md @@ -853,8 +853,9 @@ Three properties worth knowing before quoting either: - **Competitors are mandatory.** The existing `skill-agent:` arm offers one skill, which makes selection a binary did-it-load question. The real failure is picking the wrong - *sibling*: measured lexically over the 33-skill corpus, every `dpnp-*` skill scored - **0.0** recall@1 against its siblings. + *sibling*. Siblings built from one template have identical heading queries, so heading-query + recall@1 at the family level measures the template; check sibling confusion with queries + written independently of the skills. - **The queries must come from outside the artifact**, or it defines both the problem scope and its own relevance. `--queries-from-headings` exists for triage, warns, and stamps `queries_self_derived: true` into the result; corpus-wide those self-derived queries gave diff --git a/dokimi/dokimi/commands/novelty.py b/dokimi/dokimi/commands/novelty.py index 56168f525..96d375518 100644 --- a/dokimi/dokimi/commands/novelty.py +++ b/dokimi/dokimi/commands/novelty.py @@ -41,7 +41,7 @@ routing_queries, save_result, stop_if_partial, - warn_blind_skills, + warn_query_problems, ) from dokimi.defaults import ( DEFAULT_ANSWER_MODEL, @@ -350,7 +350,7 @@ def _cmd_routing_all_vs_all(args: argparse.Namespace) -> None: if without: print(f"WARNING: {len(without)} skill(s) in the field have no queries, so they only " f"compete: {', '.join(sorted(without))}", file=sys.stderr) - warn_blind_skills(result, queries_by_skill) + warn_query_problems(result, queries_by_skill) model_arm = result.get("model") or {} failed = model_arm.get("n_failed", 0) @@ -587,7 +587,7 @@ def register(sub, positive_int) -> None: routing_p.add_argument("--competitors", nargs="+", default=None, help="Other artifacts the selector sees. Selection among one " "candidate is not routing: the sibling confusion is the " - "failure mode (every `dpnp-*` skill scored 0.0 lexically)") + "failure mode (check it with independently written queries)") routing_p.add_argument("--catalog", default=None, help="Directory of skills to take the competitors from: every " "skill the catalog lists (skills.yaml when present, else every " diff --git a/dokimi/dokimi/commands/novelty_routing_report.py b/dokimi/dokimi/commands/novelty_routing_report.py index 67f427ca5..c2d421b9d 100644 --- a/dokimi/dokimi/commands/novelty_routing_report.py +++ b/dokimi/dokimi/commands/novelty_routing_report.py @@ -649,6 +649,15 @@ def warn_blind_skills(result: dict, queries_by_skill: dict[str, list[str]]) -> N "(no content word in them), so their lexical own_rate is None", file=sys.stderr) +def warn_query_problems(result: dict, queries_by_skill: dict[str, list[str]]) -> None: + """Warn on stderr of queries the lexical arm cannot score and of queries shared by skills.""" + from dokimi.novelty.query_overlap import shared_queries_warning + + warn_blind_skills(result, queries_by_skill) + if shared := shared_queries_warning(queries_by_skill): + print(f"WARNING: {shared}", file=sys.stderr) + + def field_record(args: argparse.Namespace, competitors: list[Candidate], left_out: list[str], dropped_queries: list[dict] | None = None, gold: Candidate | None = None) -> dict: """The field rule a routing result was measured under, saved next to the result. diff --git a/dokimi/dokimi/novelty/query_overlap.py b/dokimi/dokimi/novelty/query_overlap.py new file mode 100644 index 000000000..941ea9da3 --- /dev/null +++ b/dokimi/dokimi/novelty/query_overlap.py @@ -0,0 +1,37 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Queries that several skills share: a field-wide run on them says nothing about descriptions.""" + +from __future__ import annotations + +# Share of the given queries listed for more than one skill at which a run is warned. +SHARED_QUERY_WARN_SHARE = 0.5 + + +def _key(query: str) -> str: + return " ".join(query.casefold().split()) + + +def shared_queries_warning(queries_by_skill: dict[str, list[str]]) -> str | None: + """Warning text when half or more of the queries are shared by several skills, else ``None``. + + A query counts as shared when its text (case and spacing aside) is listed for two or more + skills. Sibling skills built from one template have identical heading queries, so each + skill then gets the same rank and the result says nothing about the descriptions. + """ + owners: dict[str, set[str]] = {} + for name, queries in queries_by_skill.items(): + for query in queries: + owners.setdefault(_key(query), set()).add(name) + total = sum(len(queries) for queries in queries_by_skill.values()) + if len(queries_by_skill) < 2 or not total: + return None + shared = sum(1 for queries in queries_by_skill.values() for query in queries + if len(owners[_key(query)]) > 1) + share = shared / total + if share < SHARED_QUERY_WARN_SHARE: + return None + return (f"{shared} of {total} queries ({share:.0%}) are listed for more than one skill, as " + "when sibling skills built from one template give the same heading queries: every " + "skill then gets the same rank, and the result says nothing about the descriptions") diff --git a/dokimi/dokimi/novelty/routing.py b/dokimi/dokimi/novelty/routing.py index 40c2d106e..9f9066aac 100644 --- a/dokimi/dokimi/novelty/routing.py +++ b/dokimi/dokimi/novelty/routing.py @@ -14,9 +14,9 @@ * **Lexical** — BM25 over each candidate's `name + description`, which is the text a tool schema shows. Free, deterministic, and a floor: measured over the 33-skill study corpus it ranked the right skill first for only 0.225 of queries drawn from the skills' own - section headings, and every `dpnp-*` sibling scored 0.0. A description that cannot beat - BM25 among its siblings is not discriminating; one that can has not yet been shown to be - picked by a model. + section headings. Siblings from one template (`dpnp-*`) share heading queries, so for a + family that figure measures the template. A description that cannot beat BM25 among its + siblings on independent queries is not discriminating; one that can is not yet shown picked. * **Model** — ask a model to choose from the same cards. This is the number people mean by "routing", and it is an **upper bound on harness behaviour**: a harness that truncates or omits descriptions in its own skill listing selects on less than this measurement shows, @@ -195,8 +195,8 @@ def lexical_routing( "per_query": per_query, "note": ( "A floor, and free. Over the 33-skill study corpus BM25 put the right skill " - "first for 0.225 of queries drawn from the skills' own headings, and 0.0 for " - "every `dpnp-*` sibling: descriptions discriminate a domain, not a sibling." + "first for 0.225 of queries drawn from the skills' own headings. Template siblings " + "share heading queries: check sibling confusion with independently written ones." ), } diff --git a/dokimi/tests/test_novelty_routing_shared_queries.py b/dokimi/tests/test_novelty_routing_shared_queries.py new file mode 100644 index 000000000..448a26ef4 --- /dev/null +++ b/dokimi/tests/test_novelty_routing_shared_queries.py @@ -0,0 +1,58 @@ +# Copyright (C) 2026 Intel Corporation +# SPDX-License-Identifier: Apache-2.0 + +"""Heading queries shared by sibling skills: the warning, and a lexical note that claims no finding.""" + +from __future__ import annotations + +from dokimi.commands.novelty_routing_report import warn_query_problems +from dokimi.novelty.query_overlap import SHARED_QUERY_WARN_SHARE, shared_queries_warning +from dokimi.novelty.routing import Candidate, headings_as_queries, lexical_routing + +TEMPLATE = "## Quick Start\n\n## Implementation Guide\n\n## Gotchas & Limitations\n" + + +def test_sibling_skills_on_one_template_give_identical_heading_queries(): + queries = headings_as_queries(TEMPLATE) + warning = shared_queries_warning({"dpnp-a": queries, "dpnp-b": queries, "dpnp-c": queries}) + assert warning is not None + assert "9 of 9 queries (100%)" in warning + assert "same rank" in warning + + +def test_case_and_spacing_do_not_hide_a_shared_query(): + warning = shared_queries_warning({"a": ["Quick Start"], "b": ["quick start"]}) + assert warning is not None + assert "2 of 2" in warning + + +def test_the_threshold_is_half_of_the_queries_listed_for_several_skills(): + assert SHARED_QUERY_WARN_SHARE == 0.5 + at = {"a": ["shared one", "own a1"], "b": ["shared one", "own b1"]} + below = {"a": ["shared one", "own a1", "own a2"], "b": ["shared one", "own b1", "own b2"]} + assert shared_queries_warning(at) is not None # 2 of 4 + assert shared_queries_warning(below) is None # 2 of 6 + + +def test_queries_written_per_skill_draw_no_warning(): + assert shared_queries_warning({"a": ["profile a binary"], "b": ["slice an array"]}) is None + + +def test_one_skill_or_no_queries_draw_no_warning(): + assert shared_queries_warning({"a": ["x y", "x y"]}) is None + assert shared_queries_warning({"a": [], "b": []}) is None + + +def test_the_warning_goes_to_stderr_next_to_the_other_query_warnings(capsys): + shared = headings_as_queries(TEMPLATE) + warn_query_problems({}, {"dpnp-a": shared, "dpnp-b": shared}) + captured = capsys.readouterr() + assert captured.out == "" + assert captured.err.startswith("WARNING: 6 of 6 queries (100%)") + + +def test_the_lexical_note_does_not_present_the_family_zero_as_a_finding(): + result = lexical_routing([Candidate("a", "alpha beta"), Candidate("b", "gamma")], + ["alpha beta"]) + assert "0.0 for every" not in result["note"] + assert "independently" in result["note"] From 0ce0e09d61b5427ad15ac3c3cda9c62a1d3a3bde Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Fri, 13 Nov 2026 12:13:59 +0000 Subject: [PATCH 2/7] docs(novelty): the changelog fragment names the command `novelty routing --all-vs-all` (#679) The fragment of the heading-query change said `skill novelty routing --all-vs-all`, a command that does not exist at this head; it now says `novelty routing --all-vs-all`. Signed-off-by: Anton Volkov --- dokimi/changelog.d/20261113_150000_heading-query-artifact.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dokimi/changelog.d/20261113_150000_heading-query-artifact.md b/dokimi/changelog.d/20261113_150000_heading-query-artifact.md index 502d9ea80..fa42688ce 100644 --- a/dokimi/changelog.d/20261113_150000_heading-query-artifact.md +++ b/dokimi/changelog.d/20261113_150000_heading-query-artifact.md @@ -1,3 +1,3 @@ ### Fixed -- **The dpnp 0.0 recall@1 is no longer presented as a finding** — the novelty docs, the `--competitors` help and the `note` of every lexical routing result said every `dpnp-*` skill scored 0.0 against its siblings. That figure came from queries drawn from the skills' section headings, which are one template across the family, so it measured the template, not the descriptions. They now say so and point to queries written independently of the skills. `skill novelty routing --all-vs-all` warns on stderr when half or more of the queries in `--queries-by-skill` are listed for more than one skill, since every skill then gets the same rank. +- **The dpnp 0.0 recall@1 is no longer presented as a finding** — the novelty docs, the `--competitors` help and the `note` of every lexical routing result said every `dpnp-*` skill scored 0.0 against its siblings. That figure came from queries drawn from the skills' section headings, which are one template across the family, so it measured the template, not the descriptions. They now say so and point to queries written independently of the skills. `novelty routing --all-vs-all` warns on stderr when half or more of the queries in `--queries-by-skill` are listed for more than one skill, since every skill then gets the same rank. From ab26e00b915ada9fb8249a22a48a5ac466ff16f1 Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:12:37 -0600 Subject: [PATCH 3/7] fix(novelty): shared-query warning no longer claims equal ranks, skips unscored queries, runs before the estimate - the wording says the same text is the gold of several skills and recommends independent queries; the same-rank claim is gone from the warning, docstring and changelog - queries with no content word are left out of the shared count and the total - the warning is printed before the --estimate-only return and before any model call - tests run --all-vs-all and --estimate-only and check the stderr warning Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- .../20261113_150000_heading-query-artifact.md | 2 +- dokimi/dokimi/commands/novelty.py | 6 ++- .../dokimi/commands/novelty_routing_report.py | 5 +- dokimi/dokimi/novelty/query_overlap.py | 21 +++++--- .../test_novelty_routing_shared_queries.py | 51 +++++++++++++++++-- 5 files changed, 67 insertions(+), 18 deletions(-) diff --git a/dokimi/changelog.d/20261113_150000_heading-query-artifact.md b/dokimi/changelog.d/20261113_150000_heading-query-artifact.md index fa42688ce..ff2ac9db4 100644 --- a/dokimi/changelog.d/20261113_150000_heading-query-artifact.md +++ b/dokimi/changelog.d/20261113_150000_heading-query-artifact.md @@ -1,3 +1,3 @@ ### Fixed -- **The dpnp 0.0 recall@1 is no longer presented as a finding** — the novelty docs, the `--competitors` help and the `note` of every lexical routing result said every `dpnp-*` skill scored 0.0 against its siblings. That figure came from queries drawn from the skills' section headings, which are one template across the family, so it measured the template, not the descriptions. They now say so and point to queries written independently of the skills. `novelty routing --all-vs-all` warns on stderr when half or more of the queries in `--queries-by-skill` are listed for more than one skill, since every skill then gets the same rank. +- **The dpnp 0.0 recall@1 is no longer presented as a finding** — the novelty docs, the `--competitors` help and the `note` of every lexical routing result said every `dpnp-*` skill scored 0.0 against its siblings. That figure came from queries drawn from the skills' section headings, which are one template across the family, so it measured the template, not the descriptions. They now say so and point to queries written independently of the skills. `novelty routing --all-vs-all` warns on stderr when half or more of the queries in `--queries-by-skill` are listed for more than one skill, before the estimate and before any model call, since the same text is then the gold of several skills and the result reflects the queries, not the descriptions. diff --git a/dokimi/dokimi/commands/novelty.py b/dokimi/dokimi/commands/novelty.py index 96d375518..372daebd8 100644 --- a/dokimi/dokimi/commands/novelty.py +++ b/dokimi/dokimi/commands/novelty.py @@ -41,7 +41,8 @@ routing_queries, save_result, stop_if_partial, - warn_query_problems, + warn_blind_skills, + warn_shared_queries, ) from dokimi.defaults import ( DEFAULT_ANSWER_MODEL, @@ -331,6 +332,7 @@ def _cmd_routing_all_vs_all(args: argparse.Namespace) -> None: check_all_vs_all_field(args, field, dropped, conflicts) queries_by_skill, dropped_queries = drop_queries_left_out(queries_by_skill, field, dropped, unloaded) + warn_shared_queries(queries_by_skill) if args.estimate_only: print_all_vs_all_estimate(args, field, queries_by_skill) return @@ -350,7 +352,7 @@ def _cmd_routing_all_vs_all(args: argparse.Namespace) -> None: if without: print(f"WARNING: {len(without)} skill(s) in the field have no queries, so they only " f"compete: {', '.join(sorted(without))}", file=sys.stderr) - warn_query_problems(result, queries_by_skill) + warn_blind_skills(result, queries_by_skill) model_arm = result.get("model") or {} failed = model_arm.get("n_failed", 0) diff --git a/dokimi/dokimi/commands/novelty_routing_report.py b/dokimi/dokimi/commands/novelty_routing_report.py index c2d421b9d..6ec1ba98a 100644 --- a/dokimi/dokimi/commands/novelty_routing_report.py +++ b/dokimi/dokimi/commands/novelty_routing_report.py @@ -649,11 +649,10 @@ def warn_blind_skills(result: dict, queries_by_skill: dict[str, list[str]]) -> N "(no content word in them), so their lexical own_rate is None", file=sys.stderr) -def warn_query_problems(result: dict, queries_by_skill: dict[str, list[str]]) -> None: - """Warn on stderr of queries the lexical arm cannot score and of queries shared by skills.""" +def warn_shared_queries(queries_by_skill: dict[str, list[str]]) -> None: + """Warn on stderr when half or more of the queries are listed for several skills.""" from dokimi.novelty.query_overlap import shared_queries_warning - warn_blind_skills(result, queries_by_skill) if shared := shared_queries_warning(queries_by_skill): print(f"WARNING: {shared}", file=sys.stderr) diff --git a/dokimi/dokimi/novelty/query_overlap.py b/dokimi/dokimi/novelty/query_overlap.py index 941ea9da3..80fc73ec0 100644 --- a/dokimi/dokimi/novelty/query_overlap.py +++ b/dokimi/dokimi/novelty/query_overlap.py @@ -5,6 +5,8 @@ from __future__ import annotations +from dokimi.novelty.routing import tokenize + # Share of the given queries listed for more than one skill at which a run is warned. SHARED_QUERY_WARN_SHARE = 0.5 @@ -17,21 +19,24 @@ def shared_queries_warning(queries_by_skill: dict[str, list[str]]) -> str | None """Warning text when half or more of the queries are shared by several skills, else ``None``. A query counts as shared when its text (case and spacing aside) is listed for two or more - skills. Sibling skills built from one template have identical heading queries, so each - skill then gets the same rank and the result says nothing about the descriptions. + skills. Sibling skills built from one template have identical heading queries, so the same + text is the gold of several skills and the result reflects the template, not the descriptions. + Queries with no content word are not scored by either arm and are not counted. """ + scored = {name: [q for q in queries if tokenize(q)] for name, queries in queries_by_skill.items()} owners: dict[str, set[str]] = {} - for name, queries in queries_by_skill.items(): + for name, queries in scored.items(): for query in queries: owners.setdefault(_key(query), set()).add(name) - total = sum(len(queries) for queries in queries_by_skill.values()) - if len(queries_by_skill) < 2 or not total: + total = sum(len(queries) for queries in scored.values()) + if len(scored) < 2 or not total: return None - shared = sum(1 for queries in queries_by_skill.values() for query in queries + shared = sum(1 for queries in scored.values() for query in queries if len(owners[_key(query)]) > 1) share = shared / total if share < SHARED_QUERY_WARN_SHARE: return None return (f"{shared} of {total} queries ({share:.0%}) are listed for more than one skill, as " - "when sibling skills built from one template give the same heading queries: every " - "skill then gets the same rank, and the result says nothing about the descriptions") + "when sibling skills built from one template give the same heading queries: the same " + "text is the gold of several skills, so the result reflects the queries, not the " + "descriptions; use queries written independently for each skill") diff --git a/dokimi/tests/test_novelty_routing_shared_queries.py b/dokimi/tests/test_novelty_routing_shared_queries.py index 448a26ef4..6eb72dc72 100644 --- a/dokimi/tests/test_novelty_routing_shared_queries.py +++ b/dokimi/tests/test_novelty_routing_shared_queries.py @@ -5,7 +5,11 @@ from __future__ import annotations -from dokimi.commands.novelty_routing_report import warn_query_problems +import json + +from dokimi.cli import build_parser +from dokimi.commands.novelty_routing_report import warn_shared_queries +from dokimi.novelty.all_vs_all import all_vs_all from dokimi.novelty.query_overlap import SHARED_QUERY_WARN_SHARE, shared_queries_warning from dokimi.novelty.routing import Candidate, headings_as_queries, lexical_routing @@ -17,7 +21,8 @@ def test_sibling_skills_on_one_template_give_identical_heading_queries(): warning = shared_queries_warning({"dpnp-a": queries, "dpnp-b": queries, "dpnp-c": queries}) assert warning is not None assert "9 of 9 queries (100%)" in warning - assert "same rank" in warning + assert "same rank" not in warning + assert "independent" in warning def test_case_and_spacing_do_not_hide_a_shared_query(): @@ -43,9 +48,22 @@ def test_one_skill_or_no_queries_draw_no_warning(): assert shared_queries_warning({"a": [], "b": []}) is None -def test_the_warning_goes_to_stderr_next_to_the_other_query_warnings(capsys): +def test_the_warning_does_not_claim_equal_ranks_the_run_does_not_give(): + cands = [Candidate("a", "alpha"), Candidate("b", "gamma")] + queries = {"a": ["alpha"], "b": ["alpha"]} + rates = all_vs_all(cands, queries)["lexical"]["own_rate"] + assert rates["a"] != rates["b"] + assert "same rank" not in shared_queries_warning(queries) + + +def test_queries_with_no_content_word_are_not_counted(): + queries = {"a": ["the", "and", "profile a binary"], "b": ["the", "and", "slice an array"]} + assert shared_queries_warning(queries) is None + + +def test_the_warning_goes_to_stderr(capsys): shared = headings_as_queries(TEMPLATE) - warn_query_problems({}, {"dpnp-a": shared, "dpnp-b": shared}) + warn_shared_queries({"dpnp-a": shared, "dpnp-b": shared}) captured = capsys.readouterr() assert captured.out == "" assert captured.err.startswith("WARNING: 6 of 6 queries (100%)") @@ -56,3 +74,28 @@ def test_the_lexical_note_does_not_present_the_family_zero_as_a_finding(): ["alpha beta"]) assert "0.0 for every" not in result["note"] assert "independently" in result["note"] + + +def _run_all_vs_all(tmp_path, *extra): + root = tmp_path / "catalog" + for name in ("a", "b"): + skill_dir = root / "skills" / name + skill_dir.mkdir(parents=True) + (skill_dir / "SKILL.md").write_text( + f"---\nname: {name}\ndescription: tune {name} things\n---\n\n# B\n", encoding="utf-8") + queries = tmp_path / "q.json" + queries.write_text(json.dumps({"a": ["quick start"], "b": ["quick start"]}), encoding="utf-8") + args = build_parser().parse_args( + ["novelty", "routing", "--all-vs-all", "--catalog", str(root), + "--queries-by-skill", str(queries), "--out", str(tmp_path / "out.json"), *extra]) + args.func(args) + + +def test_all_vs_all_warns_of_shared_queries_on_stderr(tmp_path, capsys): + _run_all_vs_all(tmp_path) + assert "WARNING: 2 of 2 queries (100%)" in capsys.readouterr().err + + +def test_estimate_only_warns_of_shared_queries_before_returning(tmp_path, capsys): + _run_all_vs_all(tmp_path, "--estimate-only") + assert "WARNING: 2 of 2 queries (100%)" in capsys.readouterr().err From bd594a703a4e428c53164fe3944a1285f7f14324 Mon Sep 17 00:00:00 2001 From: vchamarthi <123653452+vchamarthi@users.noreply.github.com> Date: Fri, 13 Nov 2026 08:19:00 -0600 Subject: [PATCH 4/7] fix(novelty): warn about shared queries from drop_queries_left_out to keep novelty.py at its baseline Signed-off-by: vchamarthi <123653452+vchamarthi@users.noreply.github.com> --- dokimi/dokimi/commands/novelty.py | 2 -- dokimi/dokimi/commands/novelty_routing_report.py | 2 ++ 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dokimi/dokimi/commands/novelty.py b/dokimi/dokimi/commands/novelty.py index f56a407f9..723b0e387 100644 --- a/dokimi/dokimi/commands/novelty.py +++ b/dokimi/dokimi/commands/novelty.py @@ -42,7 +42,6 @@ save_result, stop_if_partial, warn_blind_skills, - warn_shared_queries, ) from dokimi.defaults import ( DEFAULT_ANSWER_MODEL, @@ -332,7 +331,6 @@ def _cmd_routing_all_vs_all(args: argparse.Namespace) -> None: check_all_vs_all_field(args, field, dropped, conflicts) queries_by_skill, dropped_queries = drop_queries_left_out(queries_by_skill, field, dropped, unloaded) - warn_shared_queries(queries_by_skill) if args.estimate_only: print_all_vs_all_estimate(args, field, queries_by_skill) return diff --git a/dokimi/dokimi/commands/novelty_routing_report.py b/dokimi/dokimi/commands/novelty_routing_report.py index 6ec1ba98a..1e182366a 100644 --- a/dokimi/dokimi/commands/novelty_routing_report.py +++ b/dokimi/dokimi/commands/novelty_routing_report.py @@ -274,6 +274,7 @@ def drop_queries_left_out(queries_by_skill: dict[str, list[str]], field: list[Ca other name is more likely a misspelling. Refused too when no query is left to route. Returns the kept queries and one record per dropped skill, for the saved field record: ``{"skill", "reasons", "keep_flags", "queries"}``, so ``queries`` sums to the dropped count. + Warns on stderr of the kept queries several skills share, before any estimate or model call. """ names = {c.name for c in field} # a copy that failed to load may differ from the one that loaded, so the name is not measured @@ -304,6 +305,7 @@ def drop_queries_left_out(queries_by_skill: dict[str, list[str]], field: list[Ca why = ("" if not queries_by_skill else ": the queries name only skills the field leaves " f"out ({_left_out_list(_left_out_only(queried, field))})") raise InputError(f"no skill in the field has queries to route{why}") + warn_shared_queries(kept) return kept, records From 793118c05431d7ec2ee72d3c0824413e1d6fb493 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Fri, 13 Nov 2026 17:13:11 +0000 Subject: [PATCH 5/7] feat(novelty): record the shared-query counts in the all-vs-all result Settles the saved-result point of the review: the shared-query warning went to stderr only, so a file saved with --out from a run on shared queries read later as a normal own_rate table. `all_vs_all` now returns `shared_queries: {"shared", "total"}` next to `n_queries`, counted by the same function the warning uses (`shared_query_counts`, queries with no content word in neither count). The key is new in an artifact that has no schema, so no version changes, and files saved earlier simply lack it. The counts are always recorded, below the warning threshold too, so a reader can judge the share. The docs and a changelog fragment name the key. Signed-off-by: Anton Volkov --- ...0261113_160000_shared-queries-in-result.md | 3 +++ dokimi/docs/skill-novelty.md | 2 +- dokimi/dokimi/novelty/all_vs_all.py | 7 +++++- dokimi/dokimi/novelty/query_overlap.py | 22 +++++++++++++------ .../test_novelty_routing_shared_queries.py | 12 ++++++++++ 5 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 dokimi/changelog.d/20261113_160000_shared-queries-in-result.md diff --git a/dokimi/changelog.d/20261113_160000_shared-queries-in-result.md b/dokimi/changelog.d/20261113_160000_shared-queries-in-result.md new file mode 100644 index 000000000..87dd96ca5 --- /dev/null +++ b/dokimi/changelog.d/20261113_160000_shared-queries-in-result.md @@ -0,0 +1,3 @@ +### Added + +- **The `novelty routing --all-vs-all` result records `shared_queries`** — `{"shared": 9, "total": 9}`: how many of the queries with a content word are listed for more than one skill. The shared-query warning went to stderr only, so a file saved with `--out` from a run on queries drawn from one template read later as a normal own-rate table. Files saved before this change lack the key. diff --git a/dokimi/docs/skill-novelty.md b/dokimi/docs/skill-novelty.md index a4eb0a483..95ea53a54 100644 --- a/dokimi/docs/skill-novelty.md +++ b/dokimi/docs/skill-novelty.md @@ -797,7 +797,7 @@ with any failed model call writes the result and exits non-zero. The saved file field rule under `field` (`include_disabled`, `include_invalid`, `keep_copies_under`, the `competitors` and what was `left_out`, which also names each skill left out for a conflicting name or a spec error, and `spec_warnings`, one `{skill, length, limit, message}` per kept skill whose description is over 1024 characters); a single-artifact run -records it too. An all-vs-all run also lists in `field.dropped_queries` each skill whose queries it dropped, as +records it too. An all-vs-all result also records `shared_queries` (`{shared, total}`: how many of the queries with a content word are listed for more than one skill), so a file saved from a run on shared queries says so. An all-vs-all run also lists in `field.dropped_queries` each skill whose queries it dropped, as `{skill, reasons, keep_flags, queries}`, one record per skill, with each reason `disabled`, `invalid` or `conflict`. A query for a catalog skill that failed to load refuses the run and names the load error. It takes no `--artifact`, `--queries` or `--queries-from-headings`. `--estimate-only` prints the calls and the list-price cost of the whole run as JSON, in the shape of a single-artifact estimate, with no client built and diff --git a/dokimi/dokimi/novelty/all_vs_all.py b/dokimi/dokimi/novelty/all_vs_all.py index d39c8a534..decd2c154 100644 --- a/dokimi/dokimi/novelty/all_vs_all.py +++ b/dokimi/dokimi/novelty/all_vs_all.py @@ -17,6 +17,7 @@ from pathlib import Path from .client import LLMClient +from .query_overlap import shared_query_counts from .routing import ( BM25, Candidate, @@ -88,7 +89,9 @@ def all_vs_all(candidates: list[Candidate], queries_by_skill: dict[str, list[str attractor, and their queries are no sibling queries of each other. Returns: - ``n_skills``, ``n_queries``, ``tied_groups``, and per arm (``lexical``, ``model``): + ``n_skills``, ``n_queries``, ``shared_queries`` (``shared``: how many queries, of the + ``total`` that have a content word, are listed for two or more skills; when most are, the + result reflects the queries, not the descriptions), ``tied_groups``, and per arm (``lexical``, ``model``): ``confusion`` (gold skill to picked skill to count, ``(none)`` and ``(tie)`` included), ``own_rate`` per gold skill (``None`` when none of its queries was scored), ``n_scored`` and ``n_unscored`` per gold skill, and ``attractors`` ordered by ``wrongly_picked``. The @@ -107,9 +110,11 @@ def all_vs_all(candidates: list[Candidate], queries_by_skill: dict[str, list[str """ names = [c.name for c in candidates] _check_all_vs_all_input(names, queries_by_skill) + shared, scored = shared_query_counts(queries_by_skill) result: dict = { "n_skills": len(candidates), "n_queries": sum(len(q) for q in queries_by_skill.values()), + "shared_queries": {"shared": shared, "total": scored}, "tied_groups": _tied_groups(candidates), "lexical": _attractor_table(*_lexical_picks(candidates, queries_by_skill, names), names, _tied_groups(candidates)), diff --git a/dokimi/dokimi/novelty/query_overlap.py b/dokimi/dokimi/novelty/query_overlap.py index 80fc73ec0..4a5e839ca 100644 --- a/dokimi/dokimi/novelty/query_overlap.py +++ b/dokimi/dokimi/novelty/query_overlap.py @@ -15,13 +15,11 @@ def _key(query: str) -> str: return " ".join(query.casefold().split()) -def shared_queries_warning(queries_by_skill: dict[str, list[str]]) -> str | None: - """Warning text when half or more of the queries are shared by several skills, else ``None``. +def shared_query_counts(queries_by_skill: dict[str, list[str]]) -> tuple[int, int]: + """``(shared, total)``: the queries listed for two or more skills, and all the queries. A query counts as shared when its text (case and spacing aside) is listed for two or more - skills. Sibling skills built from one template have identical heading queries, so the same - text is the gold of several skills and the result reflects the template, not the descriptions. - Queries with no content word are not scored by either arm and are not counted. + skills. Queries with no content word are not scored by either arm and are in neither count. """ scored = {name: [q for q in queries if tokenize(q)] for name, queries in queries_by_skill.items()} owners: dict[str, set[str]] = {} @@ -29,10 +27,20 @@ def shared_queries_warning(queries_by_skill: dict[str, list[str]]) -> str | None for query in queries: owners.setdefault(_key(query), set()).add(name) total = sum(len(queries) for queries in scored.values()) - if len(scored) < 2 or not total: - return None shared = sum(1 for queries in scored.values() for query in queries if len(owners[_key(query)]) > 1) + return shared, total + + +def shared_queries_warning(queries_by_skill: dict[str, list[str]]) -> str | None: + """Warning text when half or more of the queries are shared by several skills, else ``None``. + + Sibling skills built from one template have identical heading queries, so the same text is + the gold of several skills and the result reflects the template, not the descriptions. + """ + shared, total = shared_query_counts(queries_by_skill) + if len(queries_by_skill) < 2 or not total: + return None share = shared / total if share < SHARED_QUERY_WARN_SHARE: return None diff --git a/dokimi/tests/test_novelty_routing_shared_queries.py b/dokimi/tests/test_novelty_routing_shared_queries.py index 6eb72dc72..bc405953b 100644 --- a/dokimi/tests/test_novelty_routing_shared_queries.py +++ b/dokimi/tests/test_novelty_routing_shared_queries.py @@ -91,6 +91,18 @@ def _run_all_vs_all(tmp_path, *extra): args.func(args) +def test_all_vs_all_result_records_the_shared_query_counts(): + cands = [Candidate("a", "alpha"), Candidate("b", "beta")] + queries = {"a": ["alpha one", "the", "own a"], "b": ["Alpha one", "own b"]} + assert all_vs_all(cands, queries)["shared_queries"] == {"shared": 2, "total": 4} + + +def test_a_saved_all_vs_all_file_carries_the_shared_query_counts(tmp_path): + _run_all_vs_all(tmp_path) + saved = json.loads((tmp_path / "out.json").read_text(encoding="utf-8")) + assert saved["result"]["shared_queries"] == {"shared": 2, "total": 2} + + def test_all_vs_all_warns_of_shared_queries_on_stderr(tmp_path, capsys): _run_all_vs_all(tmp_path) assert "WARNING: 2 of 2 queries (100%)" in capsys.readouterr().err From 2e0f9428bc95b5d3c80971db2a4e172dcd7fb671 Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Sat, 14 Nov 2026 12:50:08 +0000 Subject: [PATCH 6/7] fix(novelty): count two queries as shared when the scorer reads them as the same words MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Settles napetrov's two threads on `_key` in query_overlap.py. The key was the casefolded, space-collapsed text, while the lexical arm scores `tokenize()` output. `Quick Start` and `Quick Start.` tokenize alike but were counted apart, so the template case stayed silent; `Straße` and `STRASSE` were counted as one query though they are two BM25 terms. The key is now the token tuple the scorer uses, so both cases agree with the scoring. The tests of the saved result and the warning now run through `catalog routing`, where main moved `novelty routing --all-vs-all`, and the two fragments name that command. Signed-off-by: Anton Volkov --- .../20261113_150000_heading-query-artifact.md | 2 +- ...20261113_160000_shared-queries-in-result.md | 2 +- dokimi/dokimi/novelty/query_overlap.py | 11 ++++++----- .../test_novelty_routing_shared_queries.py | 18 +++++++++++++++--- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/dokimi/changelog.d/20261113_150000_heading-query-artifact.md b/dokimi/changelog.d/20261113_150000_heading-query-artifact.md index ff2ac9db4..418087b91 100644 --- a/dokimi/changelog.d/20261113_150000_heading-query-artifact.md +++ b/dokimi/changelog.d/20261113_150000_heading-query-artifact.md @@ -1,3 +1,3 @@ ### Fixed -- **The dpnp 0.0 recall@1 is no longer presented as a finding** — the novelty docs, the `--competitors` help and the `note` of every lexical routing result said every `dpnp-*` skill scored 0.0 against its siblings. That figure came from queries drawn from the skills' section headings, which are one template across the family, so it measured the template, not the descriptions. They now say so and point to queries written independently of the skills. `novelty routing --all-vs-all` warns on stderr when half or more of the queries in `--queries-by-skill` are listed for more than one skill, before the estimate and before any model call, since the same text is then the gold of several skills and the result reflects the queries, not the descriptions. +- **The dpnp 0.0 recall@1 is no longer presented as a finding** — the novelty docs, the `--competitors` help and the `note` of every lexical routing result said every `dpnp-*` skill scored 0.0 against its siblings. That figure came from queries drawn from the skills' section headings, which are one template across the family, so it measured the template, not the descriptions. They now say so and point to queries written independently of the skills. `catalog routing` warns on stderr when half or more of the queries in `--queries-by-skill` are listed for more than one skill, before the estimate and before any model call, since the same text is then the gold of several skills and the result reflects the queries, not the descriptions. diff --git a/dokimi/changelog.d/20261113_160000_shared-queries-in-result.md b/dokimi/changelog.d/20261113_160000_shared-queries-in-result.md index 87dd96ca5..753e3ffb0 100644 --- a/dokimi/changelog.d/20261113_160000_shared-queries-in-result.md +++ b/dokimi/changelog.d/20261113_160000_shared-queries-in-result.md @@ -1,3 +1,3 @@ ### Added -- **The `novelty routing --all-vs-all` result records `shared_queries`** — `{"shared": 9, "total": 9}`: how many of the queries with a content word are listed for more than one skill. The shared-query warning went to stderr only, so a file saved with `--out` from a run on queries drawn from one template read later as a normal own-rate table. Files saved before this change lack the key. +- **The `catalog routing` result records `shared_queries`** — `{"shared": 9, "total": 9}`: how many of the queries with a content word are listed for more than one skill. The shared-query warning went to stderr only, so a file saved with `--out` from a run on queries drawn from one template read later as a normal own-rate table. Files saved before this change lack the key. Two queries are the same when the lexical scorer reads them as the same content words: case, spacing and trailing punctuation do not tell `Quick Start` from `Quick Start.`, while `Straße` and `STRASSE` stay two queries. diff --git a/dokimi/dokimi/novelty/query_overlap.py b/dokimi/dokimi/novelty/query_overlap.py index 4a5e839ca..96a097996 100644 --- a/dokimi/dokimi/novelty/query_overlap.py +++ b/dokimi/dokimi/novelty/query_overlap.py @@ -11,18 +11,19 @@ SHARED_QUERY_WARN_SHARE = 0.5 -def _key(query: str) -> str: - return " ".join(query.casefold().split()) +def _key(query: str) -> tuple[str, ...]: + return tuple(tokenize(query)) def shared_query_counts(queries_by_skill: dict[str, list[str]]) -> tuple[int, int]: """``(shared, total)``: the queries listed for two or more skills, and all the queries. - A query counts as shared when its text (case and spacing aside) is listed for two or more - skills. Queries with no content word are not scored by either arm and are in neither count. + A query counts as shared when the lexical scorer reads it as the same content words (case, + spacing and punctuation aside) as a query of another skill. Queries with no content word are + not scored by either arm and are in neither count. """ scored = {name: [q for q in queries if tokenize(q)] for name, queries in queries_by_skill.items()} - owners: dict[str, set[str]] = {} + owners: dict[tuple[str, ...], set[str]] = {} for name, queries in scored.items(): for query in queries: owners.setdefault(_key(query), set()).add(name) diff --git a/dokimi/tests/test_novelty_routing_shared_queries.py b/dokimi/tests/test_novelty_routing_shared_queries.py index bc405953b..29b768244 100644 --- a/dokimi/tests/test_novelty_routing_shared_queries.py +++ b/dokimi/tests/test_novelty_routing_shared_queries.py @@ -10,7 +10,11 @@ from dokimi.cli import build_parser from dokimi.commands.novelty_routing_report import warn_shared_queries from dokimi.novelty.all_vs_all import all_vs_all -from dokimi.novelty.query_overlap import SHARED_QUERY_WARN_SHARE, shared_queries_warning +from dokimi.novelty.query_overlap import ( + SHARED_QUERY_WARN_SHARE, + shared_queries_warning, + shared_query_counts, +) from dokimi.novelty.routing import Candidate, headings_as_queries, lexical_routing TEMPLATE = "## Quick Start\n\n## Implementation Guide\n\n## Gotchas & Limitations\n" @@ -31,6 +35,14 @@ def test_case_and_spacing_do_not_hide_a_shared_query(): assert "2 of 2" in warning +def test_trailing_punctuation_does_not_hide_a_shared_query(): + assert shared_query_counts({"a": ["Quick Start"], "b": ["Quick Start."]}) == (2, 2) + + +def test_queries_the_scorer_tokenizes_differently_are_not_shared(): + assert shared_query_counts({"a": ["Straße"], "b": ["STRASSE"]}) == (0, 2) + + def test_the_threshold_is_half_of_the_queries_listed_for_several_skills(): assert SHARED_QUERY_WARN_SHARE == 0.5 at = {"a": ["shared one", "own a1"], "b": ["shared one", "own b1"]} @@ -86,14 +98,14 @@ def _run_all_vs_all(tmp_path, *extra): queries = tmp_path / "q.json" queries.write_text(json.dumps({"a": ["quick start"], "b": ["quick start"]}), encoding="utf-8") args = build_parser().parse_args( - ["novelty", "routing", "--all-vs-all", "--catalog", str(root), + ["catalog", "routing", str(root), "--queries-by-skill", str(queries), "--out", str(tmp_path / "out.json"), *extra]) args.func(args) def test_all_vs_all_result_records_the_shared_query_counts(): cands = [Candidate("a", "alpha"), Candidate("b", "beta")] - queries = {"a": ["alpha one", "the", "own a"], "b": ["Alpha one", "own b"]} + queries = {"a": ["alpha one", "the", "own first"], "b": ["Alpha one", "own second"]} assert all_vs_all(cands, queries)["shared_queries"] == {"shared": 2, "total": 4} From d206c17f44dde891a9635f9ceead0d900b0a9b2b Mon Sep 17 00:00:00 2001 From: Anton Volkov Date: Sat, 14 Nov 2026 13:15:04 +0000 Subject: [PATCH 7/7] fix(novelty): count two queries as shared when only their word order differs Settles the review point on query_overlap._key: BM25.score sums independent per-term contributions, so `quick start` and `start quick` score every candidate alike, yet the key kept token order and counted them as two queries. The key now sorts the tokens, keeping multiplicity, so a repeated word still tells a query apart. Signed-off-by: Anton Volkov --- .../20261113_160000_shared-queries-in-result.md | 2 +- dokimi/dokimi/novelty/query_overlap.py | 4 ++-- dokimi/tests/test_novelty_routing_shared_queries.py | 8 ++++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/dokimi/changelog.d/20261113_160000_shared-queries-in-result.md b/dokimi/changelog.d/20261113_160000_shared-queries-in-result.md index 753e3ffb0..703e91d78 100644 --- a/dokimi/changelog.d/20261113_160000_shared-queries-in-result.md +++ b/dokimi/changelog.d/20261113_160000_shared-queries-in-result.md @@ -1,3 +1,3 @@ ### Added -- **The `catalog routing` result records `shared_queries`** — `{"shared": 9, "total": 9}`: how many of the queries with a content word are listed for more than one skill. The shared-query warning went to stderr only, so a file saved with `--out` from a run on queries drawn from one template read later as a normal own-rate table. Files saved before this change lack the key. Two queries are the same when the lexical scorer reads them as the same content words: case, spacing and trailing punctuation do not tell `Quick Start` from `Quick Start.`, while `Straße` and `STRASSE` stay two queries. +- **The `catalog routing` result records `shared_queries`** — `{"shared": 9, "total": 9}`: how many of the queries with a content word are listed for more than one skill. The shared-query warning went to stderr only, so a file saved with `--out` from a run on queries drawn from one template read later as a normal own-rate table. Files saved before this change lack the key. Two queries are the same when the lexical scorer reads them as the same content words: case, spacing, trailing punctuation and word order do not tell `Quick Start` from `Quick Start.` or `start quick`, while `Straße` and `STRASSE` stay two queries. diff --git a/dokimi/dokimi/novelty/query_overlap.py b/dokimi/dokimi/novelty/query_overlap.py index 96a097996..d8dcbd3a2 100644 --- a/dokimi/dokimi/novelty/query_overlap.py +++ b/dokimi/dokimi/novelty/query_overlap.py @@ -12,14 +12,14 @@ def _key(query: str) -> tuple[str, ...]: - return tuple(tokenize(query)) + return tuple(sorted(tokenize(query))) def shared_query_counts(queries_by_skill: dict[str, list[str]]) -> tuple[int, int]: """``(shared, total)``: the queries listed for two or more skills, and all the queries. A query counts as shared when the lexical scorer reads it as the same content words (case, - spacing and punctuation aside) as a query of another skill. Queries with no content word are + spacing, punctuation and word order aside; a repeated word still counts) as a query of another skill. Queries with no content word are not scored by either arm and are in neither count. """ scored = {name: [q for q in queries if tokenize(q)] for name, queries in queries_by_skill.items()} diff --git a/dokimi/tests/test_novelty_routing_shared_queries.py b/dokimi/tests/test_novelty_routing_shared_queries.py index 29b768244..2d728e35e 100644 --- a/dokimi/tests/test_novelty_routing_shared_queries.py +++ b/dokimi/tests/test_novelty_routing_shared_queries.py @@ -39,6 +39,14 @@ def test_trailing_punctuation_does_not_hide_a_shared_query(): assert shared_query_counts({"a": ["Quick Start"], "b": ["Quick Start."]}) == (2, 2) +def test_word_order_does_not_hide_a_shared_query(): + assert shared_query_counts({"a": ["quick start"], "b": ["start quick"]}) == (2, 2) + + +def test_a_repeated_word_keeps_a_query_apart_from_the_single_word(): + assert shared_query_counts({"a": ["start start"], "b": ["start"]}) == (0, 2) + + def test_queries_the_scorer_tokenizes_differently_are_not_shared(): assert shared_query_counts({"a": ["Straße"], "b": ["STRASSE"]}) == (0, 2)