Skip to content

feat(routing): estimate-only, all-vs-all repeats and cost, description queries - #329

Open
napetrov wants to merge 9 commits into
intel:mainfrom
napetrov:fix/rescan-routing
Open

napetrov wants to merge 9 commits into
intel:mainfrom
napetrov:fix/rescan-routing

Conversation

@napetrov

Copy link
Copy Markdown
Contributor

Summary

A rescan of public skill catalogs (10-30) found five gaps in novelty routing. This PR closes them:

routing --estimate-only              prices the run as JSON, makes no model call
all-vs-all --repeats N               noise block and cost block, like routing
disabled-skill query                 refused by name, hint: --include-disabled
--queries-from-headings              falls back to description sentences; queries_source says which
--seed help                          says it fixes card order only; answer noise is --repeats

Self-derived queries stay labelled as triage (triage: self-derived, queries_self_derived: true), and queries_source is headings, description, or none. When neither gives a query the run still refuses with no_queries, and the warning no longer claims a derivation that did not happen.

Routing console lines and query loading move to commands/novelty_routing_report.py, so commands/novelty.py shrinks from 1171 to 1147 lines. The growth baseline is lowered, not raised.

Evidence

Red on intel/main: nine tests fail, six in tests/test_novelty_routing_cli_gaps.py, two in tests/test_novelty_routing_all_vs_all.py, and tests/test_novelty_routing.py::test_headings_inside_code_only_refuse_the_run_and_say_why. The two tests added after the first commit (no-derivation warning, attractor unit) were each shown red on the commit before their fix.

Local checks on this branch (aa9fcbeb):

uv run ruff check dokimi tests scripts           All checks passed!
uv run mypy dokimi                               Success: no issues found in 304 source files
uv run python scripts/check_growth.py            exit 0 (novelty.py 1147 <= baseline 1147)
uv run python -m dokimi.schema_check benchmarks/spec.v1.yaml   exit 0
uv run python -m dokimi.config_check             exit 0
uv run pytest -n auto -q                         12018 passed, 26 skipped

Self-review (pr-review local mode, 3 medium passes, diff and description only). Pass 2 found one blocker (the attractor line counted calls under the word "queries"); fixed in aa9fcbeb, and pass 3 found none. Lenses and verify were not run.

Declined review findings

  • --estimate-only exits 0 with n_calls 0 where the real run exits 2 with no_queries (no usable query). The estimate prices calls and refuses nothing; a refusal would be a separate change.
  • _SENTENCE_END splits after abbreviations such as "e.g.", so a self-derived query can be a fragment. Only the triage source is affected, and it is stamped as triage. Not measured.
  • The ASCII-only tokenizer ([a-z0-9]+) splits non-English words and gives CJK no tokens. It predates this PR, and headings_as_queries has the same limit. The new description fallback inherits it; a CJK-only description is refused as none.
  • The disabled-skill refusal loads the field a second time, which repeats some stderr notes and reports a second competitor count (the disabled skills are included). Cosmetic; the JSON is right.
  • --estimate-only is the spelling the rescan brief asked for. dry_run is the registry concept (dokimi/cli_flags.py); tests/data/cli_flags_allowlist.txt already admits this spelling for novelty analyze. Reviewers may prefer --dry-run; that is a rename, not a fix.

Not in this PR

Merge Danger

Door: two-way. The new flag and keys are additive (queries_source, cost, noise, estimate_only). Reverting restores the old refusals.

Blast Radius: low. Routing only; no catalog loading or field building is touched.

Ramifications:

  • --all-vs-all now accepts --repeats and makes N times the calls. Scripts that relied on the refusal will see model calls.
  • --queries-from-headings runs on skills it used to refuse; those runs are triage, stamped as such.
  • The CLI snapshot docs/reference/cli-interface.json is regenerated for the new option.

🤖 Generated with Claude Code

…n queries

- routing --estimate-only prints the calls, tokens and list-price USD that
  --model would make, as JSON, and makes no model call.
- all-vs-all accepts --repeats with the same noise block as routing, and
  prints a cost block; the JSON gets the cost too.
- all-vs-all refuses queries for a skill that only sets
  disable-model-invocation, names it, and suggests --include-disabled.
- --queries-from-headings falls back to the description's sentences when
  the headings give no query; queries_source records which one was used.
- --seed help says it fixes the card order only and points at --repeats
  for answer noise.
- Routing console lines and query loading move to
  commands/novelty_routing_report.py to keep novelty.py under its ratchet.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…fuses --estimate-only, repeats count calls

The model estimate skipped contentless queries that the run still sends,
--all-vs-all --estimate-only made paid calls, the stderr call count left
out the repeats, and the model arm's unscored and tied calls were printed
as queries. Fix each and cover it with a test.

Routing console output moves to commands/novelty_routing_report.py to keep
commands/novelty.py under its growth cap.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…ves nothing

With neither a heading nor a description sentence, the run refuses with
no_queries, but the warning still said the queries were derived from the
artifact. Print it only when a query was derived.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…arm repeats

With --repeats above 1 the model arm's sibling count is per call, so the
attractor line now names its unit the way the tie and unscored line does.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
@napetrov
napetrov requested a review from a team as a code owner October 31, 2026 17:48
@github-actions github-actions Bot added the cli-interface-change Changes dokimi's user-facing CLI surface label Oct 31, 2026
@github-actions

Copy link
Copy Markdown

CLI interface change detected

This PR changes dokimi's user-facing CLI surface (commands and/or options):

~ `dokimi novelty routing` parameters changed:
    + added option `--estimate-only`

Updated on every push; the cli-interface-change label is on while the diff is non-empty.

@exai-bot

exai-bot commented Oct 31, 2026 •

Copy link
Copy Markdown

✅ OpenCodeReview: Review complete: 0 finding(s) across 11 selected item(s).

Review details
  • Commit reviewed: 8611ba8 against merge base b4a0a8c
  • Coverage: 11 files in 3 groups
  • Engine: OpenCodeReview v1.13.3, us.openai.gpt-6.1-sol
  • Usage: 288k tokens (148k from cache), 30s
  • Tool calls: 20 (15 file_read, 3 code_search, 2 file_read_diff)
  • Novelty routing CLI reporting and documentation: 6 files
  • Novelty routing and all-vs-all behavior: 4 files
  • Growth baseline update: 1 file

Comment on lines +64 to +65
priced = list_cost_usd(args.model, args.provider, estimate["input_tokens"],
estimate["output_tokens"])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
Must-fix: these are run-wide token totals, but list_cost_usd prices them as one completion. For models with context-length pricing tiers (for example Gemini 2.5 Pro), many short calls can therefore cross the long-context threshold only in the estimate and be charged the higher rate, although every real request stays below it. A short query with a sufficiently large --repeats reproduces this; changing repeats should not change the per-call tariff. Price each query's prompt and one pick separately, then sum and multiply by repeats. Add a tiered-pricing test; the current linear pricing mock cannot detect this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 1c5348b. The estimate now prices each query's prompt and one pick on their own, sums them, and multiplies by the repeats, so a long-context tier applies to one call's prompt as it does in a real run. Pinned by test_estimate_prices_each_call_so_a_long_context_tier_is_not_reached_by_the_run_total, which uses a tiered stub; it failed on the previous head (0.568 against the 0.284 expected).

f"{len(queries_by_skill)} skills with queries")
_print_attractors(result)
print_attractors(result)
print_all_vs_all_model(model_arm, payload.get("cost"))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

bug · medium
Must-fix: this reporting call is unreachable whenever any model call fails, because the earlier if failed raises SystemExit. For example, the existing partial-failure test (one successful call followed by two failures), extended with --repeats 3, saves cost/noise in the file but prints neither on stdout. This hides usage from successful calls and breaks the new console-reporting promise. Move this call before the failure exit, while keeping the success-only summary after it, and extend the partial-failure test to assert cost/noise output and a non-zero exit.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 2ccd64b. The cost and noise lines now print before the failure exit, so a partial run shows the usage of the calls that ran. Pinned by test_a_partial_failure_still_prints_the_cost_of_the_calls_that_ran, which found no cost line before the fix. On a successful run those two lines now come before the OK Saved line instead of after the attractors.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correction to my earlier reply on this thread. I wrote that the success path was unchanged. It was not: a6fc22d dropped the noise and cost lines from a clean all-vs-all run.

Fixed in 8611ba8. A clean run now prints the summary, the attractors, then the noise and cost lines, as on main. A partial run prints the noise and cost lines before the failure exit, so the usage of the calls that ran is still shown.

Pinned by test_a_partial_failure_still_prints_the_cost_of_the_calls_that_ran and test_a_clean_all_vs_all_run_still_prints_its_cost_after_the_summary.

@codecov-commenter

codecov-commenter commented Oct 31, 2026 •

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 99.33775% with 1 line in your changes missing coverage. Please review.
✅ Project coverage is 94.05%. Comparing base (b4a0a8c) to head (8611ba8).

Files with missing lines Patch % Lines
dokimi/dokimi/commands/novelty.py 96.77% 0 Missing and 1 partial ⚠️
❗ Your organization needs to install the Codecov GitHub app to enable full functionality.
Additional details and impacted files
@@           Coverage Diff           @@
##             main     #329   +/-   ##
=======================================
  Coverage   94.05%   94.05%           
=======================================
  Files         297      298    +1     
  Lines       38018    38104   +86     
  Branches     6424     6437   +13     
=======================================
+ Hits        35756    35840   +84     
- Misses       1452     1454    +2     
  Partials      810      810           
Files with missing lines Coverage Δ
dokimi/dokimi/commands/novelty_routing_report.py 100.00% <100.00%> (ø)
dokimi/dokimi/novelty/all_vs_all.py 99.18% <100.00%> (+0.01%) ⬆️
dokimi/dokimi/novelty/routing.py 96.86% <100.00%> (+0.22%) ⬆️
dokimi/dokimi/commands/novelty.py 95.07% <96.77%> (-0.25%) ⬇️

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

The estimate priced the run's summed prompt and picks as one completion. A
long-context tier applies to one call's prompt, so many short calls could be
charged the higher rate although no real request reaches it. Each query's
prompt and one pick are now priced on their own, summed and multiplied by the
repeats.

Thread: PRRT_kwDOVBg7g86yTJiq
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
The failure exit came before the cost and noise lines, so a run with some
failed calls hid the usage of the calls that succeeded. Those lines now print
before the exit; the success summary stays after it.

Thread: PRRT_kwDOVBg7g86yTJix
Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
The per-call pricing added in 1c5348b returns None for an unpriced model; no
test reached that path, which left a patch-coverage gap.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>
…he partial exit

CI on a6fc22d failed the growth ratchet: novelty.py was 1149 lines
against a baseline of 1147. The partial-failure exit moves into
exit_partial_all_vs_all in novelty_routing_report.py, so the command
body shrinks. The success path keeps its original order: the summary,
then the attractors, then the noise and cost lines. A test pins that
the cost line still follows the saved-file line on a clean run.
The baseline drops to 1146 as check_growth.py --update reports.

Signed-off-by: Nikolay Petrov <nikolay.a.petrov@intel.com>

@vlad-perevezentsev vlad-perevezentsev 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.

Review at 8611ba8. --estimate-only, --all-vs-all --repeats and the all-vs-all cost block work as described. The description fallback of --queries-from-headings needs another look: its queries are the gold card's own text, so a skill that used to give no_queries now gets a rate near 1.0.

What I ran, at 8611ba8:

  • make ci-local --offline: 11,990 passed, 0 failed, 58 skipped; all checks pass.
  • A repro of the description fallback: one-word headings, a three-sentence description, and three competitors that share its words. Lexical R@1 1.0, MRR 1.0, queries_source: description.
  • --estimate-only on an artifact the real run refuses: exit 0, n_calls: 0, expected_usd: 0.0.
  • An all-vs-all query file that names a disabled catalog skill: the refusal is right, but the field is built twice, so stderr shows two different --catalog counts.

Checked, no issues

  • Estimate pricing: each call is priced on its own prompt and then multiplied by the repeats, so a long-context tier is not reached by the run total. The tiered-stub test pins this.
  • Partial all-vs-all exit: the noise and cost lines print before the failure exit. A clean run prints them after the attractors, as on main.
  • Repeats in all-vs-all: model_routing returns each query's repeats next to each other, so the slices in _model_arm group them correctly for _noise. The call count on stderr is multiplied by the repeats.
  • CLI snapshot and docs: cli-interface.json, the --seed help and skill-novelty.md match the new flags.

Comment on lines +431 to +453
def description_as_queries(description: str, *, limit: int = 40) -> list[str]:
"""Sentences of a description, for triage when the artifact has no usable heading.

A skill whose body is a ``#`` title and no ``##`` heading of two or more words still says
what it is for in its description. Each sentence with two or more content words is
offered, as :func:`headings_as_queries` offers headings. The same self-derived stamp applies.
"""
sentences = _SENTENCE_END.split(" ".join(description.split()))
return [s for s in sentences if len(tokenize(s)) >= 2][:limit]


def self_derived_queries(body: str, description: str) -> tuple[list[str], str]:
"""The triage queries of an artifact and where they came from.

Headings are preferred; the description's sentences are the fallback. Returns
``(queries, source)`` with ``source`` ``"headings"``, ``"description"``, or ``"none"`` when
neither gives a query of two or more content words.
"""
headings = headings_as_queries(body)
if headings:
return headings, "headings"
sentences = description_as_queries(description)
return sentences, "description" if sentences else "none"

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.

Blocking: The description fallback routes the gold card's own text, so its rates are near 1.0 by construction.

The gold card both arms rank is name + description, and these queries are sentences of that same description, so every query appears word for word in the gold card. Headings at least come from the body, which is not on the card. A repro: gold grill with the description "Grill the user about a plan. Use when stress-testing a design. Ask hard questions about tradeoffs." and only a one-word ## Steps heading, against three competitors that share plan, design, user and questions: queries_source: description, lexical R@1 1.0, MRR 1.0. Before this PR the same skill gave status: no_queries. So the 24 of 144 skills that were "not measured" now get a near-perfect figure, with the same triage stamp as a heading run, and a corpus-wide figure like the 0.225 in the docs moves up because of them. The changelog does not say this.

Suggestion: keep the no_queries refusal for a skill with no usable heading. If the fallback stays, the rate lines and the docs should say that a description-derived rate is a self-match and not comparable with a heading-derived one, and a corpus summary should leave these skills out.

Comment on lines +517 to +519
if args.estimate_only:
print_routing_estimate(args, candidates, queries)
return

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.

--estimate-only returns before the no-usable-queries refusal, so it prices a run that would be refused as a free, valid run.

Repro: an artifact with the description Stop. and only ## Steps, run as novelty routing --queries-from-headings --model claude-sonnet-4-5 --estimate-only, prints n_calls: 0, expected_usd: 0.0 and exits 0. The same command without the flag exits non-zero with "no usable queries" and writes a no_queries record. The same happens with a --queries file of only stop words. A batch script that budgets with the estimate sees a zero-cost run instead of a refusal.

Suggestion: check tokenize on the queries (as selection_prompt_tokens already does) before the estimate branch, and exit with the same reason the real run gives.

Comment on lines +435 to +436
everyone = _routing_field(argparse.Namespace(**{**vars(args), "include_disabled": True}),
set())

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.

_refuse_queries_naming_disabled builds the field a second time, so every field warning prints twice, with a wrong competitor count.

Repro: a catalog with one disable-model-invocation skill and a queries file that names it. stderr shows --catalog ...: 5 competitors and then --catalog ...: 6 competitors before the refusal; the second count includes the disabled skill, which is not in the field. Skipped-competitor and no-description warnings repeat too, and a large catalog is read twice.

Suggestion: the first _routing_field call already appends <name>: disable-model-invocation to left_out (the catalog notes too); pass left_out in and take the disabled names from it instead of loading the field again.

gold=index, seed=seed + index)
gold=index, seed=seed + index, repeats=repeats)
rows = routed["per_query"]
groups += [rows[start:start + repeats] for start in range(0, len(rows), repeats)]

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.

The all-vs-all noise.per_query rows pool every skill's queries but do not say which gold each row belongs to.

In a single-artifact run there is one gold, so query is enough. Here two skills can list the same request (profile my binary for perf and for profiler), and you get two rows with the same query and different n_correct, with nothing to tell them apart or to read agreement per skill.

Suggestion: add the gold skill name to each group's row (for example gold) when building groups in _model_arm.

return [h for h in headings if len(tokenize(h)) >= 2][:limit]


_SENTENCE_END = re.compile(r"(?<=[.!?])\s+")

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.

Nit: The sentence split breaks after any period and space, so e.g. / i.e. / vs. cut one sentence into two queries.

"Profile code, e.g. PyTorch training loops, and report hotspots." gives "Profile code, e.g." and "PyTorch training loops, and report hotspots."; both have two content words, so both are routed.

Suggestion: do not split after a short lowercase token ending in a period, or skip common abbreviations.


from .client import LLMClient
from .routing import BM25, Candidate, RoutingInputError, model_routing, tokenize
from .routing import BM25, Candidate, RoutingInputError, _noise, model_routing, tokenize

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.

Nit: all_vs_all.py now imports the private _noise from routing.py.

Someone changing a _ helper in routing.py will assume it has no users outside the module.

Suggestion: rename it to a public name (for example repeat_noise) now that two modules use it.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

cli-interface-change Changes dokimi's user-facing CLI surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants