Skip to content

fix(evaluation): NOT_EVALUATED metric no longer masked by a passing one - #6682

Open
gaurav-gandhi-2411 wants to merge 12 commits into
google:mainfrom
gaurav-gandhi-2411:fix/local-eval-service-not-evaluated-verdict
Open

fix(evaluation): NOT_EVALUATED metric no longer masked by a passing one#6682
gaurav-gandhi-2411 wants to merge 12 commits into
google:mainfrom
gaurav-gandhi-2411:fix/local-eval-service-not-evaluated-verdict

Conversation

@gaurav-gandhi-2411

Copy link
Copy Markdown

🔴 Required Information

Describe the Bug:
LocalEvalService._generate_final_eval_status computes an eval case's overall status from its per-metric results. It sets PASSED whenever a PASSED result is seen, and uses a bare continue on NOT_EVALUATED that leaves an already-set PASSED untouched. _evaluate_metric_for_eval_case catches any exception during a single metric's evaluation (a judge-model API failure, a rate limit, etc.) and records that metric as NOT_EVALUATED rather than letting the exception propagate — by design, so one metric's failure doesn't take down the others. But nothing downstream then treats that NOT_EVALUATED as 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 return PASSED — the crashed metric is silently absorbed. FAILED was already handled correctly in both orderings (it breaks the loop immediately), which is what pins this down as specifically a PASSED-vs-NOT_EVALUATED bug 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 concluded PASSED, report NOT_EVALUATED instead. FAILED still takes precedence over NOT_EVALUATED in all orderings, since a genuine failure is real evidence, not a missing verdict. This uses only the three existing EvalStatus values (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 confirming FAILED still dominates NOT_EVALUATED regardless of order. Confirmed the two new PASSED/NOT_EVALUATED-ordering tests fail on main with the exact bug described above, and pass after the fix.

tests/unittests/evaluation/test_local_eval_service.py -k generate_final_eval_status: 4 passed
tests/unittests/evaluation/: 786 passed

_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.
@adk-bot adk-bot added the eval [Component] This issue is related to evaluation label Aug 11, 2026
@i-yliu i-yliu assigned i-yliu and unassigned ankursharmas Aug 12, 2026
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

eval [Component] This issue is related to evaluation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants