fix(evaluation): NOT_EVALUATED metric no longer masked by a passing one - #6682
Open
gaurav-gandhi-2411 wants to merge 12 commits into
Open
fix(evaluation): NOT_EVALUATED metric no longer masked by a passing one#6682gaurav-gandhi-2411 wants to merge 12 commits into
gaurav-gandhi-2411 wants to merge 12 commits into
Conversation
_generate_final_eval_status looped over an eval case's per-metric results and set the final status to PASSED whenever a PASSED result was seen, using a bare `continue` on NOT_EVALUATED that left an already-set PASSED untouched. A metric that crashed mid-evaluation (caught in _evaluate_metric_for_eval_case and recorded as NOT_EVALUATED) therefore had no effect on the final verdict as long as some other metric in the same eval case passed: [NOT_EVALUATED, PASSED] and [PASSED, NOT_EVALUATED] both reported PASSED, silently dropping the fact that one of the requested metrics never actually produced a verdict. This is order-dependent in a way that has no principled justification -- the two orderings represent the same set of per-metric outcomes and must produce the same final status. FAILED already dominated NOT_EVALUATED correctly in both orderings (it breaks the loop immediately), which is what confirms this was specifically a PASSED-vs-NOT_EVALUATED bug rather than intended behavior. Fix uses the existing EvalStatus values only: if any metric was NOT_EVALUATED and no metric FAILED, the final status is NOT_EVALUATED rather than PASSED. A genuine FAILED still takes precedence over NOT_EVALUATED, since it's real evidence rather than a missing verdict. Added test_generate_final_eval_status_not_evaluated_then_passed_is_not_evaluated and the reverse-order counterpart to make the order-dependence visible, plus a same-precedence test confirming FAILED still wins over NOT_EVALUATED regardless of order. All four generate_final_eval_status tests, including the pre-existing doesn_t_throw_on one, and the full evaluation/ suite (786 tests) pass after the fix.
3 tasks
gaurav-gandhi-2411
added a commit
to gaurav-gandhi-2411/adk-tracegauge
that referenced
this pull request
Aug 15, 2026
…ult, console-script fix (#6) * docs: add Phase 1 diagnosis and Phase 2 plan Phase 1 diagnosis (read-only audit) accepted with six corrections; Phase 2 reframes the package from a cost gauge to a cost regression gate for ADK evals. See PLAN.md for the corrected work item list. * fix(pricing): verify and correct Gemini price table against live rates -- P0 Phase 1 flagged (docs/audit/PHASE1_DIAGNOSIS.md, C4 in PLAN.md) that the package's core promise -- a maintained, correct Gemini USD price table -- had never actually been verified against Google's published rates. This was P0: every other Phase 2 work item depends on the prices being right. Verified every model in the table against https://ai.google.dev/gemini-api/docs/pricing (fetched 2026-08-14) and found four real correctness defects, all fixed: - gemini-3.6-flash was priced at $1.50/$7.50 (the POST-2026-12-31 rate); the actual current standard rate is $0.75/$3.75 (promotional pricing through 2026-12-31). A ~2x overcharge for every gemini-3.6-flash call today. - Three current Gemini models were missing entirely: gemini-3.7-flash, gemini-3.1-flash-lite, gemini-3.1-pro-preview. - Long-context tiering (>200k prompt tokens bills roughly double) exists for gemini-2.5-pro and gemini-3.1-pro-preview and was completely unmodeled -- any long-context call was silently under-priced. Fixed via a new resolve_model_for_call(model_version, prompt_token_count) that re-resolves to a synthetic "<model>-long-context" table entry above each model's published threshold; resolve_model itself is unchanged (existing callers keep getting the base rate). - Two Gemini usage_metadata token categories were silently dropped rather than priced: thoughts_token_count ("thinking" tokens, billed as output per Google's pricing pages -- now folded into token_count_output) and tool_use_prompt_token_count (server-side built-in tool use, e.g. Google Search grounding). No verified billing rate exists for the latter, so rather than guess, any call reporting it nonzero now fails closed (score=None, AdaptResult.unpriced_component) instead of under-reporting cost. Cache-read pricing was independently re-verified and found already correct: prompt_token_count includes cached tokens (confirmed from the installed google-genai SDK's own usage_metadata docstring), so fresh_tokens = prompt_token_count - cache_read is not double-billing, and the 0.1x cache-read multiplier matches Google's published rate at every tier for every model checked. Batch API discount was confirmed genuinely out of scope: ADK's live-agent plugin path never observes a Batch API call (source-grepped google-adk's models/plugins modules) -- Batch is a separate async job-submission surface that doesn't flow through Runner/plugins. Also: STALE_THRESHOLD_DAYS tightened 180 -> 90 days after finding the old window would have let a promotional-rate expiry go undetected; price_as_of now threads through every priced rationale so the number's provenance travels with it; a weekly CI freshness gate (.github/workflows/price-freshness.yml, scripts/check_price_freshness.py) now catches staleness even during a quiet period with no new commits, independent of the existing commit-time test. 30 new tests (67 -> 97 passing, 99% coverage, same single pre-existing structurally-unreachable line as Phase 1): exact-value assertions per model against the freshly-fetched figures, a hand-calculated cached-token discount test, tiering-boundary tests at 200,000/200,001 tokens, and a staleness test that verifies only the actually-stale model is flagged. * docs(plan): check off W1 in the Phase 2 tracker Checkpoint per rule 118 -- record what's done/verified so context survives into W2 (threshold gate), which depends on price_as_of now being in the rationale. * feat(evaluator): CostEfficiencyEvaluator returns real PASSED/FAILED -- fixes P0/D1 Phase 1 found CostEfficiencyEvaluator's eval_status was permanently NOT_EVALUATED, and ADK's own AgentEvaluator.evaluate() failure classifier treats NOT_EVALUATED identically to FAILED -- so registering this metric with AgentEvaluator.evaluate() raised AssertionError unconditionally, and adk eval recorded score:null in both the printed table and the persisted eval_history/*.evalset_result.json. This was the package's single P0, blocking its own stated primary integration point. Redesign: a priceable invocation now always resolves to a real PASSED/FAILED verdict, computed directly by this evaluator (cost<=threshold -> PASSED; the opposite direction from ADK's built-in score>=threshold convention, which assumes higher-is-better and has no inverted-metric concept anywhere in google.adk.evaluation -- confirmed by source read). New CostThresholdCriterion(BaseCriterion) reuses the existing `threshold` field rather than inventing a parallel shape. Construction now requires a threshold (criterion= preferred, deprecated eval_metric.threshold= supported) and raises ValueError if neither is set -- deliberately no silent always-PASS default, which would be exactly the kind of gate that looks green while checking nothing for a package now positioned as "the cost regression gate for ADK evals" (see PLAN.md). An invocation whose cost genuinely cannot be verified (no usage captured, unresolved model, streaming anomaly, unpriced token category) still reports NOT_EVALUATED -- a distinct, legitimate case, not the old bug. Per-case overall_eval_status deliberately uses "FAILED dominates, else PASSED if at least one invocation passed" rather than "PASSED only if every invocation passed": source-confirmed LocalEvalService blanks every per-invocation result for a metric across the whole eval case whenever that metric's overall_eval_status is NOT_EVALUATED, so a stricter rule would silently destroy real per-invocation data in the common case of one eval case mixing a priced+passing invocation with an unpriceable one. Proven end to end against the real, installed, unpatched google-adk==2.6.3: - `adk eval` CLI run twice against a deterministic fixed-cost fake model (threshold=5.00 -> PASSED, threshold=1.00 -> FAILED), both with a real non-null score in the printed table AND the persisted eval_history/*.evalset_result.json -- the literal Phase 1 regression. - Two new persistent tests drive the real AgentEvaluator.evaluate(): one proves the P0 (unconditional AssertionError, "no threshold avoids this") is fixed -- a threshold now exists where it completes cleanly. The other documents a real, source-confirmed residual ADK-side limitation this package cannot fix from its own code: agent_evaluator.py's _process_metrics_and_get_failures recomputes PASSED/FAILED itself from raw scores and the deprecated legacy threshold field via mean(scores)>=threshold (hardcoded higher-is-better, ignoring this evaluator's own eval_status), always populated the same way by get_eval_metrics_from_config regardless of config shape -- so it can still misclassify a genuinely-under-budget run as FAILED. A permissive legacy-field sentinel (0.0) was considered and rejected: it would make that one harness's gate permanently PASS regardless of real cost, worse than the original bug. adk eval/LocalEvalService are unaffected (read this evaluator's real eval_status directly) -- the primary target per this phase's reframe, fully and correctly fixed with no caveats. Neither of GG's two open upstream PRs (google/adk-python#6682, #6710) touches _process_metrics_and_get_failures -- independent finding, not blocking this fix. 97->107 tests passing, 99% coverage (one pre-existing uncovered line, unrelated to W2). README's "Read this first" and four other sections corrected -- they described the now-fixed bug as permanent; a full rewrite remains W5 scope. * docs(plan): fill in W2's real commit SHA in the Phase 2 tracker The W2 entry was written before the commit it describes existed, per the same PLAN.md-update-in-the-commit-itself constraint W1's checkpoint hit. * feat(pricing): multi-provider support Extends the price table beyond Gemini to Claude and current-generation GPT models reachable through ADK's LiteLlm integration, and gives local/self-hosted models (Ollama, vLLM) an explicit zero-cost path instead of the dead-end score=None every non-Gemini model hit before this. Why: Phase 1's live smoke test against a local Ollama model confirmed this dead end firsthand, and the package can't credibly call itself "the cost regression gate for ADK evals" while only pricing one of the three providers ADK's own LiteLlm wrapper actually reaches. - 9 new priced entries (claude-opus-5/sonnet-5/haiku-4-5/opus-4-8, gpt-5/5.1/5.6-sol/terra/luna), each VERIFIED against the vendor's own pricing page as of 2026-08-14, with a fetch discrepancy on gpt-5.1 investigated and resolved rather than silently picked. Legacy GPT-4/o-series deliberately excluded: their cache-read discount diverges from every other entry's 0.1x, and tracegauge's cost engine has one global cache multiplier for the whole table -- adding them would silently mis-price cached calls by 2.5x-5x. - Local models resolve to a real zero-cost table entry (__local_zero_cost__) via a new, explicit resolve_model_for_call short-circuit, not a bypass -- keeps them on the same pricing/threshold-gate pipeline as any priced call, with an explicit "(local model, zero marginal cost)" rationale line. - resolve_model strips LiteLlm provider prefixes for first-party routes (anthropic/, openai/) and a second dated-suffix convention (-YYYY-MM-DD); bedrock/vertex_ai/azure routes are deliberately left unresolved since pricing there can diverge from first-party rates. - New ADK_TRACEGAUGE_PRICE_TABLE env var (mirrors tracegauge's own TES_PRICE_TABLE) lets a caller register a custom price without a plugin system. - unknown_model_message rewritten: no longer says "Gemini price table" (stale once the table stopped being Gemini-only), names the exact failing model, and points at the extension mechanism. 107->152 tests (45 new), 99% coverage (100% on the two files this touched most). ruff/mypy clean. * feat(cli): add tracegauge check regression gate Ships the package's core differentiator per the accepted Phase 2 roadmap (docs/audit/PHASE1_DIAGNOSIS.md SS6): trace collection is commoditized (5 vendors + ADK-native all capture spans/tokens for free); trace-based regression detection is not -- no competitor ships an ADK-specific, statistically-honest CI cost gate. New `tracegauge` console entry point with two subcommands: - `tracegauge snapshot --entrypoint module:callable --output path.json`: runs a zero-arg callable that drives your real eval, then persists the captured per-invocation cost distribution to a new JSON snapshot format (schema_version=1) -- nothing in this repo previously persisted a UsageStore's contents. - `tracegauge check --baseline b.json --current c.json [options]`: a percentile bootstrap (stdlib-only, 10,000 resamples, seed=42) on the difference in per-invocation mean cost. A regression requires BOTH statistical significance (bootstrap CI lower bound > 0) AND practical significance (effect clears --min-effect-usd OR --min-effect-pct, default $0.0001 / 5%) -- a statistically-real-but-trivial delta from a huge sample must not fail a build on its own. Refuses to emit a verdict below --min-n (default 30, the standard CLT/bootstrap-stability threshold), reporting a distinct exit code (3) instead of a statistically meaningless pass/fail. Every run prints n, CI bounds, and effect size, not only on failure. Deliberately stdlib-only (random/statistics/math), not numpy/scipy, even though both are already-transitive deps via google-adk[eval]'s scikit-learn/pandas chain -- an undeclared transitive dependency for the package's core differentiator was judged too fragile for a package positioned as "a small focused tool". Refactored compute_session_cost's sole sanctioned call site out of evaluator.py into _adapter.price_digest so snapshot.py could become a second real caller without violating the existing structural guard (test_pricing_call_site.py) against the historical wrong-price-table bug; evaluator._price_digest kept as a thin alias for backward-compatible imports. Validated against two synthetic fixtures (tests/test_regression.py, both permanent regression tests, fully deterministic): a known +20% injected cost regression correctly fires (measured +14.87% effect, 95% CI [+0.001007, +0.002023]); the measured false-positive rate under pure sampling noise (250 independent trials, no true difference) is 5/250 = 2.00%, in line with the ~2.5% nominal one-sided expectation at 95% confidence -- no evidence of miscalibration. GitHub Actions snippet (run eval -> snapshot -> compare -> fail build on regression) written to docs/ci-snippet.md as the canonical source for W5's README rewrite. 152->199 tests passing (+47), 99% coverage. ruff/ruff-format/mypy clean. * fix(compat): support Python 3.10 -- datetime.UTC only exists on 3.11+ snapshot.py imported datetime.UTC (added in 3.11), silently breaking every real invocation of build_snapshot() on Python 3.10 despite pyproject.toml's requires-python (>=3.10) and classifiers claiming support. Found while verifying the W6 CI matrix by actually running the full suite under a fresh Python 3.10.20 install (not just adding the matrix and hoping) -- collection failed with "ImportError: cannot import name 'UTC' from 'datetime'". Also fixes ruff's target-version (py311 -> py310) to match requires-python's real floor: with py311 set, ruff's own pyupgrade rule (UP017) was actively suggesting datetime.UTC over datetime.timezone.utc, which would silently reintroduce this same 3.10 breakage on the next contribution. timezone.utc has been in the stdlib since Python 3.2, so this is not a functional change on any supported version -- confirmed by an unchanged 199/199 passing, 99% coverage on both Python 3.10.20 and 3.13.5. * ci: run the test matrix across Python 3.10-3.13 Previously only 3.11 was exercised despite pyproject.toml's requires-python (>=3.10) and classifiers claiming 3.10-3.13 support (Phase 1 finding D3). Lint/format/mypy steps still run once (on 3.11 only, via an `if:` guard) since ruff/mypy output doesn't vary by interpreter -- only pytest actually needs to run on every leg. Verified locally before committing this (not just added and hoped): full 199-test suite run against fresh uv-managed Python 3.10.20 and 3.13.5 installs (the two extremes; 3.11/3.12 already covered by the existing local .venv and the pre-matrix CI history respectively) -- both pass clean at 99% coverage, after the prior commit's datetime.UTC fix. CI itself (ubuntu-latest, all 4 versions) is out of scope for this session per the branch's no-push constraint; this matrix is committed but not yet proven green in GitHub Actions. * chore(deps): bump google-adk pin to admit the live 2.7.0 release Phase 1 flagged the pin (<2.7.0) as excluding the current PyPI release, working "by luck, not by verified tooling" since the weekly canary CI had never run (D2). Now that the full Phase 2 suite exists (199 tests, up from 67 in Phase 1), actually verified google-adk 2.7.0 for real rather than assuming Phase 1's narrower smoke test still holds: installed 2.7.0 in a scratch venv (google-adk[eval]==2.7.0, --no-deps over the locked 2.6.3 base) and ran the full suite -- 199 passed, 99% coverage, no code changes required. Bumped to <2.8.0, not further: checked PyPI's JSON API directly (https://pypi.org/pypi/google-adk/json) and 2.8.0 does not exist yet, so there is nothing past 2.7.0 to verify or admit. uv.lock's own resolution stays pinned at 2.6.3 (uv's default conservative resolution keeps existing lock entries when a widened range doesn't require a change) -- this commit only widens what the pin *admits*, consistent with W6's brief. Canary dispatch (`gh workflow run pypi-canary.yml`) deferred: this branch is not pushed (session constraint), and workflow_dispatch can only target a pushed ref. TODO for whoever pushes this branch. * ci(release): create a GitHub Release after every successful PyPI publish Phase 1 found 3 git tags (v0.1.0rc1, v0.1.0, v0.2.0) with no corresponding GitHub Release objects -- tags-only, no changelog surfaced on the repo's Releases page (D9). Adds `gh release create --generate-notes` as the final step, gated to run only after the PyPI publish step succeeds (a Release should never exist for a tag that didn't actually publish). Requires bumping job-level `contents` from read to write for the default GITHUB_TOKEN to create releases. The 3 existing tags were backfilled directly via `gh release create <tag> --generate-notes` (not part of this diff -- a live GitHub write, not a repo file change): v0.1.0rc1, v0.1.0, v0.2.0 all now have real auto-generated release notes citing their actual merged PRs. * test(registration): strengthen 2 shallow not-None assertions Phase 1 (D14) found 2 of 135 assertions in the otherwise substantively-behavioral test suite were shallow `is not None` checks on TraceGaugeUsagePlugin/DEFAULT_USAGE_STORE. Replaced with real identity checks against the internal module's own symbols (matching the existing CostEfficiencyEvaluator assertion's pattern in the same test) plus a type check on DEFAULT_USAGE_STORE -- these actually prove the public re-export is the same object as the source of truth, not merely that the __init__.py re-export line didn't raise. * docs(plan): check off W6 in the Phase 2 tracker Findings summary for the 6 hygiene sub-items, commit SHAs, and the 3 TODOs deferred to after this branch is pushed (canary dispatch, real CI-matrix confirmation on ubuntu-latest, optional remote branch deletion). * feat(compat): wrap the private convert_events_to_eval_invocations call -- W5 5.1 Confirmed by grep: nothing under src/adk_tracegauge/ calls ADK's private EvaluationGenerator.convert_events_to_eval_invocations -- W2's after_model_callback + adk eval/AgentEvaluator (this phase's primary documented path) never needs it, since LocalEvalService/AgentEvaluator do their own internal Event->Invocation conversion. It's still needed by the optional hand-rolled sub-agent-rollup harness, so wrap it behind _compat.convert_events_to_eval_invocations: a best-effort version check against a known-tested range (warns, doesn't block -- an out-of-range version is often still compatible, per W6's 2.7.0 finding) plus a clear, actionable RuntimeError (naming the installed version and which integration path is affected) instead of a bare ImportError/AttributeError if the internal has moved. test_e2e_runner.py updated to go through the wrapper instead of importing EvaluationGenerator directly. * docs(examples): add 3 runnable, verified examples -- W5 5.3 examples/01_minimal_cost_gate.py: the quickstart pattern (after_model_callback + real adk eval CLI), run for real this session -- both PASSED (threshold above real cost) and FAILED (below) verdicts captured with real dollar figures. Surfaces a real finding along the way: adk eval's own process exit code does not reflect PASSED/FAILED (verified live, 0 in both runs) -- worth knowing before wiring adk eval into CI. examples/02_subagent_rollup.py: a real two-agent AgentTool delegation through InMemoryRunner (no mocking) -- root $0.525 across two turns + delegated sub-agent $0.04 = $0.565 rolled up, matching a hand-computed check against the price table. examples/03_ci_regression_gate.py: tracegauge snapshot + tracegauge check as real subprocesses (via `python -m adk_tracegauge._cli`, not `python -c` -- the latter doesn't propagate main()'s return value into the process exit code) -- a genuine +20%-mean injected regression detected, real exit code 1. Every example has a header stating how to run it and what output to expect, and was actually executed (not just written) before committing. * docs: rewrite README with a working quickstart above the fold -- W5 5.2/5.4/5.5/5.7 Leads with install -> register the metric with a threshold -> run adk eval -> real PASS/FAIL verdict, using real measured numbers from this session's own run (examples/01_minimal_cost_gate.py): 4 lines of adk-tracegauge- specific Python + 1 line of threshold config, zero private-API calls, 31.6s wall-clock for both PASS and FAIL adk eval runs. All limitations (the AgentEvaluator.evaluate() pytest-helper residual issue, adk eval's own exit-code non-propagation, Gemini/Claude/GPT-only pricing scope) moved to a clearly-marked "Known limitations" section below the quickstart -- present, not buried, not leading. Badges added: PyPI version, CI status, Python versions, License -- all 4 verified live this session (HTTP 200, real non-placeholder SVG content). Real passing + failing captures for both adk eval and tracegauge check included as fenced code blocks (this session's own runs, not fabricated). DEFAULT_USAGE_STORE documented (Phase 1 D11) -- new subsection explaining why it exists (ADK's MetricEvaluatorRegistry has no channel for a custom store at construction time) and when to pass store= explicitly instead. New docs/troubleshooting.md: the 3 deferred Phase 1 misconfiguration errors, triggered live this session and captured verbatim -- wrong google-adk version (real ModuleNotFoundError from a scratch venv forced to google-adk==1.0.0), unknown model (real actionable warning text), missing threshold (real ValueError text). "What this is not" updated for the new product statement -- still not a tracing/observability replacement for Phoenix/Langfuse, but no longer honest to disclaim being a threshold gate now that it is one. * docs: add CHANGELOG, CONTRIBUTING, and GitHub issue templates -- W5 5.6 CHANGELOG.md: retroactive 0.1.0rc1/0.1.0/0.2.0 entries derived from `gh release view <tag>` and git log (not invented), plus an Unreleased section for this phase's actual Added/Changed/Fixed. Proposes 0.3.0 as the next version per this project's own 0.x convention (middle digit for breaking changes pre-1.0), justified by W2's real breaking change (CostEfficiencyEvaluator now requires a threshold, raises ValueError instead of the old permanent NOT_EVALUATED). pyproject.toml's version is NOT bumped and nothing is tagged/published -- out of this work item's scope. CONTRIBUTING.md: dev setup (uv sync --frozen), test/lint/mypy commands, the project's branch/commit conventions, and why price-freshness.yml and pypi-canary.yml are scheduled (not just push-triggered) CI jobs. .github/ISSUE_TEMPLATE/bug_report.yml and price_correction.yml -- the latter a structured way to report a stale/wrong price, directly tied to the price-freshness mechanism this package already has. * docs(plan): check off W5 in the Phase 2 tracker * docs: add Phase 2 report Closes out the cost-regression-gate build: price-table diff table, adk eval end-to-end proof, W4 statistical validation with an adversarial different-seed re-check, and the human TODO list for after this branch is reviewed and pushed. * fix(pricing): require explicit opt-in before pricing local-model calls at $0.00 Phase 3 B1 (release-blocking): is_local_model() treated any ollama_chat/, ollama/, or vllm/ prefix as automatically zero-cost. Ollama Cloud is a real paid product routed through the identical ollama_chat//ollama/ LiteLlm prefix as local Ollama -- only the api_base/host differs, and that field is confirmed NOT reachable at the point a real call lands in TraceGaugeUsagePlugin.after_model_callback (read directly: LlmResponse is a pydantic model with extra="forbid" and no host/endpoint field; neither CallbackContext nor the InvocationContext it wraps expose the underlying LiteLlm model client instance where api_base is actually stored). A genuinely paid Ollama Cloud call would have been silently priced at $0.00. resolve_model_for_call now fails closed (returns None, NOT_EVALUATED upstream) for any local-prefixed model unless the caller explicitly asserts it via ADK_TRACEGAUGE_ASSUME_LOCAL=1 (all recognized prefixes) or a comma-separated subset (e.g. "vllm/", to trust one prefix while still failing closed on ollama_chat/). unknown_model_message names the exact opt-in remedy for a local-prefixed-but-unasserted model instead of the generic "register a custom price" text. A wrong $0.00 is worse than a loud, actionable refusal to price. * fix(pricing): auto-switch promotional price entries to standard_rate on expiry Phase 3 B2 (release-blocking): promotional/introductory price entries had no mechanism to stop being wrong once the promo period ended -- an entry frozen at its promotional rate past expiry silently undercharges every call against it. gemini-3.6-flash and gemini-3.7-flash (promotional through 2026-12-31, re-verified live against ai.google.dev) are the two entries this currently applies to; claude-sonnet-5's historical "introductory pricing" language was investigated and confirmed settled (the vendor's own page states the scheduled increase "will not occur"), so it deliberately gets no promo_until/standard_rate -- there is no future rate to auto-switch to. Schema gains optional promo_until (ISO date) + standard_rate ({input_usd_per_mtok, output_usd_per_mtok}) per entry. resolve_model/ resolve_model_for_call report the effective rate for "today" automatically -- promotional while promo_until hasn't passed (inclusive of the boundary day, matching vendor phrasing like "through December 31"), standard once past it, no manual table edit required. Since tracegauge's own compute_session_cost reads prices["models"][key]["input_usd_per_mtok"] directly off whatever dict it's given, price_digest (the single sanctioned call site) now routes every prices argument through the new effective_prices() before handing it to tracegauge's engine, so the auto-switch reaches the actual computed dollar total, not just a ResolvedModel field. The evaluator's per-turn rationale states explicitly whether a promo is active (with its expiry date) or has ended (with "standard rate applied automatically"). A promotional entry whose post-promo rate is genuinely unpublished warns loudly, starting 14 days before expiry (not just at the exact expiry instant, and continuing past it) via ResolvedModel.standard_rate_warning_due and a new evaluator warning channel -- never silently frozen at a possibly-wrong number. .github/workflows/price-freshness.yml's underlying script gains the same 14-day check as an independent CI gate, reporting "expiring soon" and "already expired" as two distinct conditions. * docs(plan): record Phase 3 B1/B2 findings in the tracker Phase 3 fixed two release-blockers flagged by Phase 2's verification pass (Ollama Cloud silent-zero, promotional pricing time bomb) -- see commits eac066e and 6d6f98a for the implementation. * fix(evaluator): emit a real runtime warning when driven by AgentEvaluator.evaluate() Phase 3 B3, 3.2. Phase 2 documented (README, module docstring, a permanent regression test) that AgentEvaluator.evaluate()'s pytest-style harness recomputes PASSED/FAILED via mean(scores) >= threshold, hardcoded higher-is-better, ignoring this evaluator's own correct eval_status -- directionally backward for this lower-is-better cost metric. That documentation existed, but nothing fired at runtime to warn a user before they hit an unexplained AssertionError. evaluate_invocations() now detects (via a contextvars.ContextVar set for the duration of a real AgentEvaluator.evaluate() call, installed through a defensive, best-effort monkeypatch of AgentEvaluator.evaluate as an adk_tracegauge import side effect) whether it is being driven by that specific harness, and warns explicitly, naming the exact ADK behavior and the installed google-adk version. A plain call-stack walk was tried first and empirically fails: LocalEvalService.evaluate() forks every eval case into its own asyncio.Task via asyncio.as_completed, which erases the physical call stack back to whichever caller awaited it into existence, identically for AgentEvaluator.evaluate() and adk eval. A ContextVar set before that fork survives it (Task creation copies the current context, PEP 567); adk eval/LocalEvalService never set it. Known, documented gap: the very first AgentEvaluator.evaluate() call in a process misses the warning if adk_tracegauge is imported for the first time as a side effect of that same call loading the user's agent module (the quickstart's own pattern) -- the wrap installs a moment too late for a call already in progress. Workaround documented in README: import adk_tracegauge explicitly ahead of any AgentEvaluator.evaluate() call. Proven with a subprocess-based regression test, not just asserted. Confirmed adk eval exit-code doc + tracegauge check's real exit codes were already correct/documented (Phase 2 W4/W5) -- no changes needed there beyond citing exact source line numbers in README. 245 -> 250 tests passing (+5), 99% coverage, ruff/ruff-format/mypy clean. * docs(plan): record Phase 3 B3 findings in the tracker * fix(regression): measure gate detection power and add paired comparison mode Phase 2 measured the bootstrap regression gate's false-positive rate (~2%) but never its statistical power -- the probability of actually detecting a real cost regression. A new power-grid harness (scripts/measure_regression_power.py, tests/test_regression_power.py) shows the two-sample gate does NOT reliably (>=80%) detect a 10% true regression until n=50/group -- it detects only 69% of the time at n=25, a realistic ADK eval-set size, and refuses to run at all below the default min_n=30. Root cause: the two-sample bootstrap is swamped by between-eval-case cost variance, which a paired comparison can cancel out. invocation_id cannot serve as the pairing key (google-adk always regenerates it randomly, per evaluation_generator.py/runners.py), but TraceGaugeUsagePlugin only fires through a caller-built Runner, so the caller's own session_id is a real, stable-across-runs key when pinned per eval case. Adds: UsageStore.record_session/.session_id, SnapshotRecord.session_id (additive, backward-compatible), Snapshot.costs_by_session_id/ pair_costs_by_session_id, _regression.evaluate_regression_paired (a one-sample bootstrap over per-pair deltas), and `tracegauge check --mode {auto,two-sample,paired}` -- an additional mode, not a replacement, since two-sample remains the correct fallback when no session_id is pinned. Measured slice (n=25, case-correlated generator, +$0.001/case additive regression): two-sample detects 0/200 trials, paired detects 200/200. * docs(plan): record Phase 3 B4 findings in the tracker * test: mutation-test pricing/gate logic, 0 coverage gaps found (Phase 3 B5) Re-ran Phase 1's shallow-assertion audit across all 293 tests (564 asserts, 14 files) -- zero new shallow/tautological/mock-through assertions found beyond the 2 already fixed pre-Phase-3. Applied 7 targeted mutations to pricing/gate/mode-selection logic (core dollar arithmetic, cache-read discount, threshold comparison, tiering boundary, plus B1/B2/B4's opt-in gate, promo-expiry switch, and auto-mode selection); all 7 were caught by the existing suite before any fix was needed, so no new tests were added. Full findings and the mutation results table are in PLAN.md's Phase 3 B5 entry. * docs: rewrite README around the tracegauge-check hero path with measured Phase 3 numbers Given B4's measured finding that the default two-sample regression gate is underpowered at realistic ADK eval-set sizes (n=25), and B3's confirmation that adk eval's own process exit code never reflects PASSED/FAILED, argue explicitly for tracegauge check (not the adk eval metric) as the README's hero path -- it's the only path with real, distinguishable exit codes and is the package's actual statistically-validated differentiator. The adk eval metric path stays documented as a clearly-labeled secondary path. Also: fix six stale README cross-references (in examples/, docs/ci-snippet.md, and three src/ docstrings) pointing at headings that no longer exist; find and correct troubleshooting.md's entry 2, whose captured warning text predated B1 and still claimed local models auto-resolve to zero cost; add two new troubleshooting entries (Ollama Cloud opt-in gap, tracegauge check's exit-code-3 on small eval sets) triggered and captured live this session. All numbers (LOC, wall-clock, example output) re-measured fresh this session, not carried over from prior reports. * fix(cli): put cwd on sys.path for --entrypoint resolution The installed `tracegauge` console-script entry point does not get the caller's current working directory on sys.path automatically, unlike `python -m adk_tracegauge._cli` (Python's own -m behavior) -- so the README quickstart's literal `tracegauge snapshot --entrypoint my_eval_suite:...` command, run from a plain directory after a real `pip install adk-tracegauge`, failed with "could not import module 'my_eval_suite'" even though the file sat right there in cwd. Found during Phase 3 B7's fresh-wheel-install verification (running from a source checkout or via `uv run` already has cwd on sys.path one way or another, which is why this was never observed before that test). Insert cwd onto sys.path in _resolve_entrypoint before the import, mirroring what -m already does. * docs(plan): record Phase 3 B7 findings in the tracker Final release-blocking verification packet: full suite green against live google-adk 2.7.0 across all 4 CI-claimed Python versions, real sdist/wheel packaging inspection, a genuinely fresh wheel install run from outside the repo (which found and fixed a real cwd/sys.path gap in the tracegauge console script), the complete current price table, fresh real adk eval PASS/FAIL runs with persisted JSON, and the consolidated ROUTE-TO-GG list for shipping this branch. * docs: add Phase 3 report Closes out the release-blocking fixes: Ollama Cloud pricing, promo expiry handling, ADK inversion guard plus prepared upstream PRs, the statistical power grid (and the honest underpowered-gate finding it produced), mutation-testing results, the README hero-path rewrite, and the final pre-push verification packet. * docs(plan): record the B5 fork-dispatch root cause Phase 4's R1 audit found missing R1's independent history review confirmed the final repo state is clean but found the B5 incident's actual causal mechanism (a dispatched fork racing its parent in the shared checkout) was never written into PLAN.md at the time -- only the resulting suspected-injection symptoms were. Recorded now as a durable addendum, sourced to the orchestrator's direct receipt of the B5 agent's own report. * fix(regression): re-key paired mode from session_id to eval_case_id for the adk eval CLI path Phase 3 B4's `tracegauge check --mode paired` was unreachable for the primary documented `adk eval` CLI workflow, for two independent reasons: (1) session_id is regenerated fresh and random on every `adk eval` run unless the eval case's own session_input.session_id is authored in the .evalset.json file (most eval sets don't set this), and (2) the ONLY capture hook that ever recorded it, before_run_callback, never fires during `adk eval`/AgentEvaluator.evaluate() at all -- both build a bare Runner with no App/Plugin wiring. Paired mode only ever worked for a hand-rolled Runner+App+Plugin harness that explicitly pins session_id, never against real adk eval, exactly how it was validated. Fix: eval_case_id (EvalCase.eval_id, authored directly in the .evalset.json file, confirmed stable across runs by reading google-adk's eval_case.py) is now the primary pairing key. It is unreachable from any live callback (no InvocationContext/Session/CallbackContext object carries it), so it's recovered post-hoc by joining ADK's own persisted .evalset_result.json file (which carries both eval_id and session_id per case) against adk-tracegauge's own live-captured session_id -- now also fixed to actually fire during `adk eval`, via after_model_callback instead of before_run_callback. - _compat.py: new load_eval_case_ids_by_session_id, guarded the same way as the existing convert_events_to_eval_invocations wrapper. - _plugin.py: after_model_callback also records session_id (the hook proven to fire through `adk eval`). - snapshot.py: SnapshotRecord.eval_case_id (additive), schema_version bumped 1->2 (still reads v1 files fine), resolve_pairing implements the fallback chain (eval_case_id -> session_id -> two-sample) and is the single place the decision is made. - _cli.py: `tracegauge snapshot --eval-history <path>` resolves the join; `tracegauge check` always prints which key was actually used. Real end-to-end proof against the actual `adk eval` CLI (not a hand-rolled harness), examples/04_paired_mode_via_adk_eval_cli.py: two real `adk eval` runs on the same 32-case evalset confirm session_id differs on every case between runs while eval_id does not, and `tracegauge check --mode paired` resolves key=eval_case_id, matches all 32 cases, and correctly detects a real injected regression (exit code 1). Tests: 294 -> 320 passing, 99% coverage (3 pre-existing uncovered lines, unchanged). ruff/mypy clean. * docs(plan): record Phase 4 R2 findings in the tracker * feat(regression): surface achieved statistical power at runtime (R4) Makes the cost-regression gate honest about its own detection limits on every `tracegauge check` run, not just in docs. B4 (Phase 3) measured 69% detection at n=25/10%-regression and a refusal below n=30; this closes the gap between that documented limitation and what a user actually sees. - 4.1: normal-approximation "minimum reliably-detectable effect at 80% power" computed from each run's own observed variance/n (bootstrap power has no closed form). New stdlib-only probit/CDF machinery (Acklam's approximation + Halley refinement). Validated against B4/R2's measured power grid at 7 points (2-8pp accuracy, worst at n=25) via a reproducible test, not just a docstring claim. Printed every run, pass/fail/ insufficient_data. - 4.2: explicit WARNING when the configured min-effect floor is below the achieved detection floor -- real example captured live via examples/03_ci_regression_gate.py (n=40, default $0.0001 floor is below the ~$0.000474 achievable floor). - 4.3: min_n re-examined against real measurement (n=30/35/40/45: 71.5%/ 79.0%/77.5%/83.0% detection) -- kept at 30. No single min_n generalizes across callers' own variance/effect-of-interest; 4.1/4.2's per-run computation is the general fix, not a bigger fixed threshold. - 4.4: real FPR at min_n=30, shipped default config (not the isolated grid), 500 trials: 4.60% (23/500), independent re-check 4.20% (21/500) -- higher than nominal because the practical floor doesn't suppress noise at this n/variance. - 4.5: BCa bootstrap implemented as a throwaway experiment and empirically measured -- no improvement (expected: BCa targets bias/skew, near-zero for a symmetric mean statistic here). Studentized bootstrap assessed and not attempted (known small-n instability, would need a nested bootstrap). Neither shipped; documented as a real, unfixed limitation. Tests: 320 -> 348 (+28), 99% coverage (_regression.py itself at 100%). Real output re-captured (byte-identical numbers) into README, the CLI example's docstring, and troubleshooting.md. * refactor(pricing): port dollar-cost arithmetic in-house, remove tracegauge dependency (R5) Audited every point of dependence on the external `tracegauge` package's undocumented internal shape (SessionDigest/TurnDigest from a module tracegauge's own docstring calls non-public, the undocumented `prices` dict schema compute_turn_cost/compute_session_cost read directly, compute_session_cost's prices=None fallback -- the exact mechanism behind a real historical mispricing bug, a dead-code default-model-fallback path, and a licensing claim never actually checked against the installed package). Wrote and ran 8 contract tests against both tracegauge versions admitted by the prior pin (0.10.0, 0.10.1) -- 8/8 passed on both, plus the full pricing-relevant suite (125 tests), confirming the two versions are arithmetic-identical and differ only in a license-header finding (0.10.0 lacks the Apache-2.0 SPDX header 0.10.1 carries). Grep-confirmed tracegauge's ~55-line arithmetic plus two internal dataclasses were the ONLY thing this package ever used from it anywhere in src/ -- none of tracegauge's actual features (self-baseline scoring, trajectory judge, waste detection, dashboard/CLI) were ever touched. Moved the arithmetic in-house (src/adk_tracegauge/_cost.py, ported verbatim from the installed source with attribution) and removed the `tracegauge` PyPI dependency entirely -- also dropping its unused transitive web-dashboard deps (flask/werkzeug/blinker/itsdangerous). Deliberately hardened one behavior: the ported compute_session_cost has no prices=None default (the historical mispricing bug's root cause, previously only guarded around). Proven behavior-identical: full 348-test suite re-run with tracegauge genuinely uninstalled, byte-identical results; 9 new port-fidelity tests hand-compute the arithmetic directly. Found and fixed a real pre-existing mypy gap along the way: tes ships with no py.typed marker, so mypy silently treated SessionDigest as Any and never checked a call site that needed narrowing -- owning the type surfaced it. Full findings, the per-version verification table, and the audit methodology are recorded in PLAN.md's Phase 4 R5 entry. * fix(docs): correct troubleshooting entry 1 for a genuinely clean install Phase 4 R7's fresh-wheel-only verification pass found that entry 1's wrong-google-adk-version reproduction fails one import frame earlier than documented when installed via a genuinely clean `uv pip install google-adk[eval]==1.0.0` (full dependency resolution): ModuleNotFoundError: No module named 'deprecated', raised from google/adk/tools/base_tool.py, before adk_tracegauge is ever imported. Root cause: google-adk==1.0.0's own PyPI metadata (all 52 Requires-Dist entries checked) never declares a dependency on the `deprecated` package under any extra, despite base_tool.py importing it unconditionally -- a real, undeclared-dependency packaging bug in that specific old release, independent of adk-tracegauge. The documented error text does reproduce exactly once `deprecated` is installed manually first. Phase 2 W5's original capture almost certainly came from a dev/editable-install venv that already had `deprecated` present transitively from some other package -- invisible until a genuinely clean, from-scratch install was attempted, exactly the class of gap this fresh-wheel testing pattern exists to catch. * feat(ci): add wheel-only install smoke test job New wheel-smoke-test job, independent of lint-and-test: builds the wheel, installs ONLY the built wheel (not editable, not the source checkout) into a fresh venv under runner.temp, cds to a workdir also under runner.temp with no relationship to the repo checkout, then runs the hero path (tracegauge snapshot x2 + check, asserting the specific expected exit code) via the literal installed console script plus one example end to end. This is a permanent, automated version of the exact manual verification Phase 3 B7 had to do by hand to find its release-blocking sys.path bug -- without this job, a future change could reintroduce that class of bug silently, since the existing lint-and-test job installs this package editable from inside the repo checkout and would never catch it. Verified the job's own logic locally before trusting it (build wheel -> fresh venv -> wheel-only install -> unrelated workdir -> hero path with exit-code assertion -> example end to end, translated only for Windows venv layout) -- all steps passed. * docs(plan): record Phase 4 R7 findings in the tracker * docs(plan): record Phase 4 R6/R3 findings in the tracker Both work items ran in separate repos (oss-contrib/adk-python and oss-contrib/adk-docs) and their dispatch prompts omitted the usual PLAN.md-update instruction. Recorded here from their session reports for continuity before writing PHASE4_REPORT.md. * docs: add Phase 4 report Closes out the correctness/honesty/process-integrity pass: the R1 history audit and its B5 root-cause addendum, the R2 pairing-key fix (the gate's flagship feature was unreachable through the documented CLI path until this phase), the R3 docs rewrite with an explicit release-sequencing constraint, R4's runtime power-awareness and the real measured FPR at n=30, R5's dependency-contract hardening and licensing-risk removal, R6's re-verification of both upstream PRs, and R7's fresh-wheel testing standard plus permanent CI job. * docs(plan): record Phase 5 S1 findings -- tracegauge live pricing defects Blocking, done first. tracegauge==0.10.1 (live on PyPI) has no price entries for the current Claude flagship models (claude-opus-5/-sonnet-5) and silently drops all server-side tool billing -- a published-package correctness incident, independently re-verified, prior in priority to the adk-tracegauge release itself. Read-only this item; no fix applied in either repo. * docs(plan): Phase 5 S2/S3 -- fork-vs-upstream decision and parity matrix S2: read both codebases' full current source; corrected the phase kickoff's premise that R5 forked primarily on a licensing claim (R5's own 6 findings show the undocumented-internal-API concern was load-bearing, licensing was finding 6 and already resolved for 0.10.1). Recommend a scoped Option C: promote adk-tracegauge's own (more correct, more complete) pricing/regression engine up into tracegauge as its public core, rather than reverting to tracegauge's current weaker copy -- Option A's mechanics, Option C's design intent, merge direction corrected by what was actually found reading both repos. Licensing confirmed genuinely resolved as of tracegauge 0.10.1 (per-file SPDX dual-license headers present, matches upstream HEAD, already documented in tracegauge's own README) -- no fix needed. Full non-executed migration plan included. S3: parity matrix across 15 capabilities -- 8 show live divergence or gaps (pricing philosophy, promo handling, staleness guard, snapshot format, check/regression-gate, paired mode, achieved-power reporting, CI coverage). Also found and flagged, independent of the S2 decision: both packages currently install a console script literally named `tracegauge`, a live naming collision. Read-only except this documentation; no source changed in either repo. * fix(regression): retune shipped default confidence 0.95 to 0.98 -- Phase 5 S4 Phase 4 R4.4 measured the shipped default's real false-positive rate at n=30 as roughly 3.93-4.4%, well above the nominal 2.5% one-sided expectation -- unacceptable for a CI gate whose entire value proposition is being trustworthy (a false alarm on about 1 in 23 clean runs trains users to ignore or disable it). Measured a full one-sided-alpha x n x true-effect grid (90 cells, alpha in 0.025/0.01/0.005 mapped to confidence 0.95/0.98/0.99, n in 10/25/30/50/100/250, effect in 0/5/10/25/50 percent, 500 trials per cell, scripts/measure_regression_alpha_grid.py) to choose the new default. confidence=0.98 cuts the real shipped-config FPR at n=30 from 4.4% to 2.3% (measure_shipped_default_fpr.py, real floors, real n_boot=10000) while keeping n=50/10%-effect detection power at 83.4%, above this codebase's own established 80%-power reliable-detection bar (ACHIEVED_POWER_TARGET). confidence=0.99 was rejected: it drives FPR lower (1.6%) but drops that same power to 76.2%, below the bar. Also confirms (4.5) the practical-significance floor remains a real, independently AND'd gate, and measures that it contributes zero additional false-positive suppression at n=30's variance level, at either the old or new confidence -- a permanent regression test now locks this in. Any caller not overriding --confidence sees a real behavior change: slightly wider CIs, fewer noise-driven regression verdicts, slightly lower detection power at small effect sizes. Passing --confidence 0.95 restores the old behavior explicitly. 3 pre-existing tests in test_regression_power.py pinned to an explicit _HISTORICAL_CONFIDENCE constant (0.95) -- they reproduce a specific Phase 3 B4 / Phase 4 R2 measurement, not the current default's behavior, so decoupling them from DEFAULT_CONFIDENCE is the correct fix rather than re-deriving new numbers for an unrelated historical reference. * docs: update README/CHANGELOG/examples for the new confidence default -- Phase 5 S4 Every number connected to Phase 4 R4's FPR/power measurements is re-stated for the new DEFAULT_CONFIDENCE=0.98 default (commit ed429e7), not left describing the retired 0.95 default: - README's Quickstart output block and Known-limitations FPR bullet regenerated from a real subprocess re-run of examples/03_ci_regression_gate.py, not hand-edited numbers. A new bullet states the FPR/power tradeoff explicitly in prose (not only in the CHANGELOG), per the work item's own requirement that a reader understand what the default configuration costs in detection power. - examples/03_ci_regression_gate.py's EXPECTED OUTPUT docstring updated to the real captured output at the new default (98% CI, updated achieved-power figure; mean/effect unchanged since the generator/seed did not change). - docs/ci-snippet.md's example --confidence flag and exit-code table updated to match the new default. - CHANGELOG.md's Unreleased/Changed section documents the default change as a real behavior-affecting change, with the measured before/after FPR and power numbers and an explicit escape hatch (--confidence 0.95) for callers who want the old behavior. * docs(plan): record Phase 5 S4 findings -- confidence retune, full 90-cell grid * test(cost): independent fidelity proof of R5's ported arithmetic vs live tracegauge -- Phase 5 S5 Extends tests/test_cost_port_fidelity.py with 110 real cases (22 price-table models x 5 token-count scenarios, incl. cached-token and long-context-tier rates) computed by BOTH adk_tracegauge._cost.compute_turn_cost and the real external tracegauge==0.10.1 package (separate scratch venv, not a dependency of this repo) -- all 110 matched bit-for-bit, zero divergence. Also adds an adk-tracegauge-only tiering-boundary resolution test (no tracegauge equivalent exists). Frozen as literal test data so CI never needs the external package installed. * docs(plan): record Phase 5 S5 findings -- final work item, closes Phase 5 4-Python-version suite (3.10-3.13) re-run against live google-adk 2.7.0: 363 passed, 99% coverage, identical across all 4. Fresh-wheel pass re-confirmed every example/README/ci-snippet/troubleshooting command and number still holds after S4's confidence-default change -- zero discrepancies found. Independent cross-package fidelity proof (S5 5.3) found zero divergence between the in-housed cost arithmetic and the real external tracegauge package across 110 cases. * docs: add Phase 5 report Closes out the fork-reconsideration pass: S1's blocking finding that live tracegauge==0.10.1 mis-prices its own maintainer's current flagship Claude models, S2's corrected architecture recommendation (tracegauge should absorb adk-tracegauge's superior pricing/stats code, not the reverse), S3's parity matrix, S4's alpha-grid FPR retune (0.95->0.98 default), S5's clean regression check, and the orchestrator's own resolution of a verifier/agent disagreement on the S5.3 port-fidelity methodology. * docs(plan): record Phase 6 T1/T2 findings T1: tracegauge 0.10.2 fail-closed pricing fix, prepared on its own branch in token-efficiency-scorer, not published. T2: blind third-party adjudication independently re-confirms the Phase 5 S5.3 port-fidelity claim and separately re-confirms the already-known bundled-price-table divergence -- settled, not re-litigated further. * fix(cli): rename console script tracegauge -> adk-tracegauge The sibling `tracegauge` PyPI package (token-efficiency-scorer) already installs a console script under the exact same name; whichever package installed second silently clobbered the other's executable (Phase 5 S2/S3 finding). Renames the [project.scripts] entry point and every self-reference that echoes the command name back to the user (argparse prog=, --help text, printed status/report lines in _cli.py and _regression.py's report()), plus every other command-usage reference repo-wide (README, CHANGELOG, docs, examples, tests, the CI wheel-smoke-test job's literal binary path). 0.2.0 (the last published release) shipped no console script at all -- the script was added post-publish in this same unreleased branch -- so this is new capability, not a breaking rename of anything a real user depended on. Verified from two fresh venvs (both install orders): `adk-tracegauge --help` and `tracegauge --help` each resolve to their own correct package's CLI, order-independent. * docs(plan): record Phase 6 T3 findings -- console-script rename * fix(compat): update the last stray console-script reference to adk-tracegauge T3's rename missed one error message inside _compat.py's RuntimeError text (caught by T3.3's independent verifier). Confirmed via a full repo sweep that no other bare 'tracegauge check'/'tracegauge snapshot' reference remains outside the historical audit reports, which are intentionally left as point-in-time records. * docs(regression): re-validate min_n=30 at confidence=0.98, sync README numbers (Phase 6 T4) Phase 4 R4's min_n=30-vs-raise decision was measured at confidence=0.95, the default at the time; Phase 5 S4 later changed the shipped default to 0.98, which lowers power at every n and left that decision unexamined against the new default. Re-measured n in {30,35,40,45,50} at confidence=0.98 for a true 10% cost regression (500 trials/cell, two independent seed bases at n=30/45/50): no n up to 45 comes close to 80% power, and n=50 -- which looked like a clean answer from Phase 5's single-measurement grid (83.4%) -- turns out marginal once measured twice more (79.6%, 81.0%; three independent measurements averaging ~81.3%, within one run's own sampling noise of exactly 80%). Raising min_n to 50 would not reliably buy 80% power for a 10% effect, only guarantee refusing every real 30-49-invocation eval set. Kept min_n=30; the existing achieved-power/minimum-detectable-effect runtime reporting (Phase 4 R4) remains the general fix, consistent with this project's established honesty-over-usability pattern. Also confirmed (git log/git show since S4's ed429e7) that no statistical logic in _regression.py changed across Phase 6 T1-T3 -- only cosmetic console-script-rename string literals. * chore(release): bump version to 0.3.0, consolidate CHANGELOG across Phases 2-6 pyproject.toml's version moves 0.2.0 -> 0.3.0 (Phase 6 T5 5.1). The [Unreleased] CHANGELOG section is moved to a dated [0.3.0] entry and rewritten to actually cover the full branch, not just whatever had accumulated in [Unreleased] by the end of Phase 2 plus a few later additions -- cross-checked against `git log main..HEAD` (54 commits) and all 5 phase reports, which surfaced 6 real, previously-undocumented gaps: the Phase 3 B1 ADK_TRACEGAUGE_ASSUME_LOCAL opt-in requirement (the local- model bullet still described pre-B1 behavior), Phase 3 B2's promotional- pricing auto-expiry, the price-freshness.yml CI job's own existence, Phase 3 B4/Phase 4 R2's --mode {auto,two-sample,paired} paired-comparison gate, Phase 4 R4's real-time achieved-power/MDE reporting, and Phase 4 R7's wheel-only install smoke-test CI job. None of these were breaking changes on their own (all pre-0.3.0, still unreleased), but a changelog that silently omits a shipped CLI mode or the headline runtime feature the README leads with isn't honest documentation. * docs(plan): record Phase 6 T5 findings -- final work item, closes the build Full 4-version suite against live google-adk 2.7.0 (365/365 passed on 3.10/3.11/3.12/3.13, identical coverage, zero code changes required), genuinely fresh-wheel re-verification of the renamed 0.3.0 console script (zero discrepancies -- the second clean pass in this project's history), sdist/wheel content inspection (data/gemini_prices.json genuinely packaged in both, twine check PASSED on both), and an adk-docs consistency check that found and fixed one real staleness bug (a paired-mode capture still showing "95% CI" from before Phase 5 S4's confidence retune to 0.98, committed locally in adk-docs as 4181f2b7). Closes with the final two-train ROUTE-TO-GG list for the entire multi-phase build, confirming the two release trains (tracegauge 0.10.2 and adk-tracegauge 0.3.0) have no ordering dependency on each other -- re-verified, not assumed, since Phase 4 R5 removed adk-tracegauge's dependency on tracegauge entirely. * docs: add Phase 6 report Closes out both release trains: tracegauge 0.10.2 (T1, urgent live-pricing fix, prepared not published), the S5.3 dispute settled by a blind third-party adjudication (T2), the console-script collision resolved (T3), the min_n=30 decision validated with fresh multi-seed power measurements (T4), and the full 0.3.0 release packet with one more real staleness bug caught in the adk-docs PR (T5). Neither release train has been pushed, tagged, or published. * feat(cli): paired mode becomes the default check --mode auto preference Phase 7 U1. `--mode auto` now PREFERS paired over two-sample whenever a pairing key (eval_case_id, preferred; else session_id) resolves with overlap >= --min-n, falling back to two-sample only when no key resolves or overlap is below that bar -- previously paired was an opt-in bonus requiring the same threshold but framed/tested as secondary. - New `_paired_mode_viable()` in _cli.py: the single named place the auto-selection threshold is decided. Value kept identical to --min-n (30) -- re-examined, not lowered: a full 20-cell paired-mode power grid (scripts/measure_paired_power_grid.py, n in {10,25,50,100} x effect in {0,5,10,25,50}%, 1,000 trials/cell, confidence=0.98, 20,000 simulated check() calls) found paired's own false-positive rate is HIGHER than two-sample's at every measured n (e.g. 4.1% vs 2.2% at n=10) -- paired buys power (97.8% vs 51.4% detection at n=25/10%-effect), not reliability, so there's no basis for a lower bar. - Partial-overlap policy (1.4): auto mode's fallback message now distinguishes "no pairing key available at all" from "a key resolved but too few pairs", instead of one conflated message. New test proves the fallback uses the FULL two-sample population, not just the small matched subset. - generate_case_correlated_pair moved from tests/test_regression_power.py into scripts/measure_regression_power.py so the new paired power-grid script and the existing test file share one definition instead of a second duplicate copy; math is byte-identical (existing tests still reproduce their exact historical numbers). - Real measurements this session: 32/32 (100%) eval_case_id overlap on the real 32-case adk-eval-CLI evalset (examples/04, re-run fresh, plus an independent re-confirmation via the 1.6 fresh-wheel proof below); the full paired power grid above; and a genuinely fresh wheel build installed into a clean venv outside the repo, running the installed adk-tracegauge console script with NO --mode flag against two real `adk eval` CLI runs -- confirmed auto-selecting mode=paired (key=eval_case_id, 32/32 matched), exit code 1 on the injected regression. - README's "Known limitations" section, examples/03's captured output, and docs/troubleshooting.md's captured output updated for the new default and message wording -- README was found stale relative to Phase 4 R2 (still described session_id as the primary key and two-sample as "the default"), fixed as part of this pass. See PLAN.md's Phase 7 U1 entry for the full grid tables and proofs. * feat(regression): re-decide DEFAULT_CONFIDENCE with paired-mode-aware evidence (Phase 7 U2) Re-measure the deciding cells (confidence x {30,50} x {0,10,25}% effect, 18 cells) at 2,000 trials/cell with Wilson score CIs, for BOTH two-sample and paired mode side by side -- Phase 5 S4's original 0.98 tuning used two-sample data only, before paired became the default --mode auto preference (U1). Finding: paired mode's power is already near-ceiling at 0.98 and barely moves at 0.99, while two-sample's power drops sharply over the same tightening and crosses below the 80%-power bar at n=50 -- reproducing S4's original criterion-2 rejection of 0.99 on the fallback path that's still real whenever no pairing key resolves. DEFAULT_CONFIDENCE stays at 0.98 (value unchanged, reasoning now rests on both modes). Also audits every power/FPR/detection-rate figure in README.md to carry its trial count and a Wilson 95% CI, per this project's metric-provenance convention. * docs: README coherence pass -- shipped-config summary and honest detection-limits section (Phase 7 U3) Adds one clear, findable statement of the shipped default (paired mode via --mode auto, with the two-sample fallback's own numbers stated separately) right after the Quickstart, plus a dedicated "What this gate can and cannot detect" section stating the honest power profile across regression sizes. Every number carries its Wilson 95% CI and trial count, reusing U1/U2's own measured grids (two new Wilson CIs computed from already-published phat/n pairs, verified against the project's own wilson_score_interval). * docs(plan): record Phase 7 U5 final re-verification -- closes the build Final work item before feat/cost-regression-gate is release-ready: full 4-Python-version suite against live google-adk 2.7.0 (382/382, 99% coverage, identical on all four), a fresh-wheel pass covering all 4 examples, the hero check path with no --mode flag plus both explicit --mode fallbacks, every runnable doc code block, sdist/wheel content inspection plus twine check, and the final Train 2 ROUTE-TO-GG list. No source, test, or doc defect found -- this entry is the only change. * docs: add Phase 7 report Closes out paired-by-default (U1) with its counter-intuitive higher-paired-FPR finding independently re-verified twice; the high-rigor 72,000-evaluation alpha re-decision with Wilson CIs on every cell (U2), keeping confidence=0.98 for a sharper reason now that paired is the default path; the honest README rewrite with no bare percentages remaining (U3); the BREAKING-labeled tracegauge 0.10.2 CHANGELOG (U4); and the final re-verification (U5), the first work item in this build to find zero problems anywhere. * fix(ci): repair YAML parse error in wheel-smoke-test's final step The quoted-interpreter-path-plus-trailing-args form (run: "${{ runner.temp }}/.../python" 03_ci_regression_gate.py) is invalid YAML -- a quoted scalar cannot have trailing content on the same line. This was never caught because the exact commit this branch is now at had only been verified locally (uv run pytest, etc.) across all 7 build phases -- this is the first time ci.yml itself ran on GitHub's real runners, where it failed before any job started ("workflow file issue", 0 jobs). Fixed by moving the command into a block scalar (run: |), the same pattern every other multi-token run step in this file already uses.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
🔴 Required Information
Describe the Bug:
LocalEvalService._generate_final_eval_statuscomputes an eval case's overall status from its per-metric results. It setsPASSEDwhenever aPASSEDresult is seen, and uses a barecontinueonNOT_EVALUATEDthat leaves an already-setPASSEDuntouched._evaluate_metric_for_eval_casecatches any exception during a single metric's evaluation (a judge-model API failure, a rate limit, etc.) and records that metric asNOT_EVALUATEDrather than letting the exception propagate — by design, so one metric's failure doesn't take down the others. But nothing downstream then treats thatNOT_EVALUATEDas reducing confidence in the final verdict.Steps to Reproduce:
Run an eval case with two requested metrics where one crashes (→
NOT_EVALUATED) and the other passes.Expected Behavior: An eval case where a requested metric never produced a verdict should not be reported as a clean
PASSED.Observed Behavior:
[NOT_EVALUATED, PASSED]and[PASSED, NOT_EVALUATED](same set of outcomes, opposite order) both currently returnPASSED— the crashed metric is silently absorbed.FAILEDwas already handled correctly in both orderings (itbreaks the loop immediately), which is what pins this down as specifically aPASSED-vs-NOT_EVALUATEDbug rather than intended behavior: there's no principled reason two orderings of the same outcomes should disagree.Why this fix
Track whether any metric was
NOT_EVALUATED; if so, and the loop would otherwise have concludedPASSED, reportNOT_EVALUATEDinstead.FAILEDstill takes precedence overNOT_EVALUATEDin all orderings, since a genuine failure is real evidence, not a missing verdict. This uses only the three existingEvalStatusvalues (PASSED/FAILED/NOT_EVALUATED) — no new status is introduced.Testing Plan
Added two tests exercising both orderings of
[NOT_EVALUATED, PASSED], named to make the order-dependence explicit, plus a same-shape test confirmingFAILEDstill dominatesNOT_EVALUATEDregardless of order. Confirmed the two new PASSED/NOT_EVALUATED-ordering tests fail onmainwith the exact bug described above, and pass after the fix.