ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
# Experiments 7-4 and 7-11: end-to-end user-memory evaluation
|
||||
|
||||
This companion runs memory systems. It does not score canned response files.
|
||||
It reuses the 60 cases in `chapter3/user-memory-evaluation/test_cases` and
|
||||
records an API-backed trajectory for every `(case, configuration)` cell.
|
||||
|
||||
← [Chapter 7 index](../README.md) · [Book acceptance criteria](../../book/chapter7.md)
|
||||
|
||||
## What is implemented
|
||||
|
||||
### Experiment 7-4: Advanced JSON Cards vs RAG vs hybrid
|
||||
|
||||
For every one of the same 60 cases, the runner independently builds and runs:
|
||||
|
||||
| System | Ingestion and answering path | Steps/tools |
|
||||
| --- | --- | --- |
|
||||
| Advanced JSON Cards | An LLM extracts structured cards containing provenance, person/relationship, exact facts, temporal status, and ambiguity; all cards stay in the answer context. | One answer step, zero retrieval tools |
|
||||
| RAG | Raw conversations are split on complete turns, embedded into a dense index, searched through an actual `search_memory` tool call, optionally reranked, then answered from top-5 chunks. | Forced retrieval plus answer |
|
||||
| Hybrid | Only cards explicitly classified `memory_tier: core` stay resident; supporting/episodic facts remain in raw conversations while the main Agent decides whether to call `search_memory`. | One or two steps; tool use is observed, not hard-coded |
|
||||
|
||||
The JSON report records success/reward, rubric dimensions, hallucination veto,
|
||||
steps, tool calls, latency, input/output tokens, cost and price-coverage gaps.
|
||||
`success` requires at least `good` (3/4) on precision, recall and reasoning plus
|
||||
no hallucination veto; `reward` still preserves partial credit.
|
||||
`failure_boundaries` lists failed cases and per-dimension weaknesses for each
|
||||
system/layer, plus a paired hybrid-synergy/regression analysis.
|
||||
|
||||
### Experiment 7-11: full component matrix
|
||||
|
||||
`default_config.yaml` sweeps all three selection points from the book:
|
||||
|
||||
- embeddings: BGE-M3, OpenAI, and an independently hosted Mistral control,
|
||||
plus a documented Qwen3 substitution for the unreachable Doubao embedding
|
||||
(see "Backend substitutions" below);
|
||||
- rerankers: no-reranker baseline, a Doubao semantic reranker (documented
|
||||
substitution for the unreachable BGE cross-encoder), and the Kimi semantic
|
||||
reranker;
|
||||
- main models: Kimi and Ark/Doubao under an identical retrieval contract.
|
||||
|
||||
### Backend substitutions (2026-07-31)
|
||||
|
||||
Acceptance is tied to equivalent providers/models, not to one vendor's
|
||||
official API. Every substitution is recorded in `default_config.yaml` and in
|
||||
the sanitized receipts `results/candidate_backend_probes_20260731.json` and
|
||||
`results/full_matrix_backend_readiness_20260731.json`:
|
||||
|
||||
- SiliconFlow's key is valid but the account balance is 0 (HTTP 402), so
|
||||
`bge-m3` runs the identical `baai/bge-m3` model via OpenRouter.
|
||||
- The direct OpenAI account has no credits (HTTP 429), so `openai-small`
|
||||
runs the identical `openai/text-embedding-3-small` via OpenRouter.
|
||||
- Ark embeddings require a console-provisioned endpoint id and every public
|
||||
Doubao embedding model name returns 404 on this account, so the Doubao
|
||||
embedding slot is honestly replaced by `qwen/qwen3-embedding-8b` via
|
||||
OpenRouter (the closest Chinese-provider multilingual embedding).
|
||||
- No cross-encoder reranker is reachable (SiliconFlow balance 0; DashScope
|
||||
gte-rerank returns 403 AccessDenied with this international key), so the
|
||||
BGE cross-encoder slot is honestly replaced by `doubao-semantic`, a second
|
||||
LLM reranker on the Doubao chat model. The matrix therefore compares
|
||||
none / Doubao-LLM / Kimi-LLM reranking; no cross-encoder is claimed.
|
||||
|
||||
A source-aware retrieval judge selects the relevant chunk IDs before the matrix
|
||||
run. Each cell is then measured with hit@5, recall@5 and MRR, as well as task
|
||||
success, rubric score, steps, tool calls, latency and cost. The report does not
|
||||
rank components in isolation: `interaction_analysis` calculates reranker value
|
||||
conditional on embedding and main model, flags observed reranker redundancy,
|
||||
and measures whether stronger main models succeed despite incomplete retrieval.
|
||||
|
||||
Embedding/reranker quality is also measured with an identical fixed user-query
|
||||
benchmark in every cell (`fixed_query_*`), avoiding main-model query wording as
|
||||
a confound. The production Agent trajectory is measured separately: retrieval
|
||||
is mandatory, but the main model may make up to three follow-up searches, so
|
||||
steps/tool calls are real efficiency signals instead of constants.
|
||||
|
||||
Provider failures become explicit `status: error` matrix records and never count
|
||||
as task failures. This prevents an unavailable account or endpoint from silently
|
||||
changing a quality comparison.
|
||||
|
||||
The report has a machine-readable `run_scope`. A run is marked `full` only when
|
||||
all 60 distinct case IDs and all configured cells completed. Filtered evidence
|
||||
is always marked `smoke`; a 60-case invocation with provider errors is marked
|
||||
`incomplete-full-suite`.
|
||||
|
||||
## Experiment 7-3 prerequisite
|
||||
|
||||
The shared judge in [`chapter3/user-memory-evaluation`](../../chapter3/user-memory-evaluation/)
|
||||
is now the structured Experiment 7-3 judge. It sees the authoritative source and
|
||||
returns four grades for precision, recall, reasoning, and proactivity, with
|
||||
evidence and boundary cases. A separate hallucination result is a hard veto.
|
||||
The runner here uses that judge for 7-4 and 7-11 task success.
|
||||
|
||||
The completed 7-4 campaign also provides the full execution evidence for 7-3:
|
||||
all 60 distinct cases across three systems produced 180/180 real structured
|
||||
judgments. [`results/full_7_3_structured_rubric_evidence.json`](results/full_7_3_structured_rubric_evidence.json)
|
||||
validates every saved record against the four-dimension contract and independent
|
||||
hallucination veto, and content-hashes the immutable source report. It is built
|
||||
by `python build_73_evidence.py`; the derivation performs no model calls and
|
||||
does not add or change any score.
|
||||
|
||||
## Install and configure
|
||||
|
||||
```bash
|
||||
cd chapter7/user-memory-system-evaluation
|
||||
python -m pip install -r requirements.txt
|
||||
cp env.example .env
|
||||
```
|
||||
|
||||
Credentials are read only from environment variables; reports never contain
|
||||
keys. `default_config.yaml` is the full book matrix. All matrix components
|
||||
carry dated list prices so `unpriced_tokens` stays zero; the report exposes
|
||||
`unpriced_tokens` so incomplete cost accounting cannot look like a zero-cost
|
||||
system.
|
||||
|
||||
## Run
|
||||
|
||||
The default is all 60 cases:
|
||||
|
||||
```bash
|
||||
python experiment.py 7-4 --config default_config.yaml \
|
||||
--output results/experiment_7_4.json
|
||||
|
||||
python experiment.py 7-11 --config default_config.yaml \
|
||||
--output results/experiment_7_11.json
|
||||
```
|
||||
|
||||
Use filters only for smoke tests:
|
||||
|
||||
```bash
|
||||
python experiment.py 7-4 --config live_config.yaml \
|
||||
--test-id layer1_01_bank_account \
|
||||
--output results/live_7_4_layer1.json
|
||||
|
||||
python experiment.py 7-11 --config live_config.yaml \
|
||||
--test-id layer1_01_bank_account \
|
||||
--output results/live_7_11_matrix_layer1.json
|
||||
```
|
||||
|
||||
Restart-safe complete campaigns:
|
||||
|
||||
```bash
|
||||
python run_full.py 7-4 --config live_config.yaml --workers 4 \
|
||||
--output results/full_7_4_60_cases.json
|
||||
|
||||
python run_full.py 7-11 --config default_config.yaml --workers 4 \
|
||||
--readiness results/full_matrix_backend_readiness.json \
|
||||
--output results/full_7_11_60_case_matrix.json
|
||||
```
|
||||
|
||||
`run_full.py` writes one case checkpoint before counting it, resumes valid
|
||||
checkpoints, and merges only direct records. A readiness file avoids repeatedly
|
||||
calling a provider already proven unavailable while still emitting every blocked
|
||||
matrix cell as `status: error`.
|
||||
|
||||
`live_config.yaml` is a known-working development-account subset. It uses real
|
||||
Mistral/Codestral embeddings, no-reranker and Kimi reranker, and Kimi/Doubao main
|
||||
models. It does not replace the full BGE/OpenAI/Doubao matrix.
|
||||
|
||||
Probe the full configuration without running 60 cases:
|
||||
|
||||
```bash
|
||||
python probe_backends.py --config default_config.yaml \
|
||||
--output results/full_matrix_backend_readiness.json
|
||||
```
|
||||
|
||||
The probe calls the actual configured chat, embedding, and reranking paths and
|
||||
stores sanitized status/error evidence. Keys are never written.
|
||||
|
||||
## Tests and checked-in live evidence
|
||||
|
||||
```bash
|
||||
pytest -q ../../chapter3/user-memory-evaluation/test_structured_rubric.py test_experiment.py
|
||||
```
|
||||
|
||||
- `results/live_7_4_core_hybrid_layer1.json`: three complete layer-1 7-4 trajectories
|
||||
using the exact core-card hybrid path.
|
||||
- `results/full_7_4_60_cases_costed.json`: canonical completed Experiment 7-4
|
||||
campaign—60 distinct cases × three systems, 180/180 real trajectories, zero
|
||||
trajectory errors, `validation_scope: full`, and complete native-currency cost
|
||||
coverage. Its top-level and completion status are both `complete`.
|
||||
- `results/live_7_11_matrix_layer1.json`: current-code live factorial 7-11 smoke
|
||||
(generated by the command above when present).
|
||||
- `../../chapter3/user-memory-evaluation/results/live_7_3_layer1.json`: live Kimi structured-rubric result.
|
||||
- `../../chapter3/user-memory-evaluation/results/live_7_3_hallucination_veto.json`:
|
||||
live Kimi proof that one unsupported number forces reward to zero.
|
||||
- `results/full_matrix_backend_readiness.json`: sanitized full-matrix endpoint probe.
|
||||
- `results/full_matrix_backend_readiness_20260731.json`: sanitized 9/9 readiness
|
||||
probe under the documented substitutions; `results/candidate_backend_probes_20260731.json`
|
||||
keeps the per-candidate rejection receipts (SiliconFlow 402 balance, OpenAI 429,
|
||||
Ark embedding 404s, DashScope rerank 403) that justify each substitution.
|
||||
|
||||
These evidence files contain synthetic benchmark answers, metrics and model
|
||||
names, but no credentials or complete source conversations. Experiment 7-4 is
|
||||
complete only through the canonical full report named above; the `live_*` files
|
||||
remain smoke evidence and must not be substituted for it.
|
||||
|
||||
Experiment 7-11 is **complete**: the full 4×3×2×60 matrix campaign finished with
|
||||
1,440/1,440 real trajectories, zero error records, and zero unpriced usage in
|
||||
`results/full_7_11_60_case_matrix.json` (top-level and completion status both
|
||||
`complete`), executed under the documented backend substitutions above
|
||||
(`results/full_matrix_backend_readiness_20260731.json`).
|
||||
`validation/verify_full_matrix_20260731.py` independently rechecks case/cell
|
||||
coverage, trajectory cleanliness, metric finiteness, pricing coverage, and the
|
||||
interaction analysis (ALL CHECKS PASSED).
|
||||
None of the earlier blockers changed the completed 7-4 status.
|
||||
|
||||
## 中文说明
|
||||
|
||||
本目录对应实验 7-4 与 7-11,实际构建并运行三种记忆系统及组件矩阵,不再对预先写好的
|
||||
回答文件打分。默认读取第三章同一套 60 个测试用例,逐条记录任务成功率、步数、工具调用、
|
||||
延迟、token、成本覆盖、top-5 检索指标和结构化 Rubric。`default_config.yaml` 是正文要求的
|
||||
BGE-M3 / OpenAI / 豆包嵌入、含无 reranker 基线、以及多主模型的完整矩阵;
|
||||
`live_config.yaml` 只是已验证账号的真实 API 冒烟子集。实验 7-3 的五维 Rubric(四个评分维度
|
||||
+ 幻觉否决)位于第三章共用评估框架,并由本目录直接复用。
|
||||
|
||||
当前状态必须按实验分别读取:实验 7-4 已由
|
||||
`results/full_7_4_60_cases_costed.json` 完成 60 用例 × 3 系统共 180/180 条真实轨迹和完整成本核算;
|
||||
实验 7-11 的 4×3×2×60 全矩阵活动已完成:`results/full_7_11_60_case_matrix.json` 收录 60 用例 × 24 单元
|
||||
共 1,440/1,440 条真实轨迹,零错误、零未定价用量,检索/任务指标与交互分析完整(顶层与 completion
|
||||
状态均为 `complete`),并由 `validation/verify_full_matrix_20260731.py` 独立复核通过。
|
||||
矩阵在后端就绪度 9/9 的如实记录替代方案下执行(见上文“Backend substitutions”)。
|
||||
@@ -0,0 +1,149 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Derive canonical Experiment 7-3 evidence from the completed 7-4 campaign.
|
||||
|
||||
The completed 7-4 report ran the Experiment 7-3 judge on every one of its
|
||||
60 cases and three memory systems. This validator creates a small, auditable
|
||||
index without changing, adding, or re-judging any paid API trajectory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
from collections import Counter, defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REQUIRED_DIMENSIONS = {"precision", "recall", "reasoning", "proactivity"}
|
||||
|
||||
|
||||
def sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--source",
|
||||
type=Path,
|
||||
default=Path(__file__).with_name("results") / "full_7_4_60_cases_costed.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=Path(__file__).with_name("results") / "full_7_3_structured_rubric_evidence.json",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
source = json.loads(args.source.read_text(encoding="utf-8"))
|
||||
records = source.get("records", [])
|
||||
valid = []
|
||||
errors = []
|
||||
by_layer_system = defaultdict(lambda: Counter(records=0, passed=0, vetoes=0))
|
||||
for index, row in enumerate(records):
|
||||
dimensions = row.get("rubric_details") or {}
|
||||
numeric = row.get("rubric_dimensions") or {}
|
||||
hallucination = row.get("hallucination_detail")
|
||||
problems = []
|
||||
if row.get("status") != "ok":
|
||||
problems.append(f"status={row.get('status')!r}")
|
||||
if set(dimensions) != REQUIRED_DIMENSIONS:
|
||||
problems.append(f"rubric_details={sorted(dimensions)}")
|
||||
if set(numeric) != REQUIRED_DIMENSIONS:
|
||||
problems.append(f"rubric_dimensions={sorted(numeric)}")
|
||||
if any(not 1 <= int(value) <= 4 for value in numeric.values()):
|
||||
problems.append("rubric score outside 1..4")
|
||||
if not isinstance(hallucination, dict) or "detected" not in hallucination:
|
||||
problems.append("missing hallucination verdict")
|
||||
for name, detail in dimensions.items():
|
||||
# A concise direct answer can legitimately have no affirmative
|
||||
# proactivity evidence. In that boundary case the judge must name
|
||||
# the applied boundary explicitly instead of inventing evidence.
|
||||
if (
|
||||
not isinstance(detail, dict)
|
||||
or not detail.get("reasoning")
|
||||
or not (detail.get("evidence") or detail.get("boundary_case"))
|
||||
):
|
||||
problems.append(f"{name} lacks reasoning and evidence/boundary")
|
||||
if problems:
|
||||
errors.append({
|
||||
"record_index": index,
|
||||
"test_id": row.get("test_id"),
|
||||
"system": row.get("system"),
|
||||
"problems": problems,
|
||||
})
|
||||
continue
|
||||
valid.append(row)
|
||||
bucket = by_layer_system[(row["layer"], row["system"])]
|
||||
bucket["records"] += 1
|
||||
bucket["passed"] += int(bool(row.get("success")))
|
||||
bucket["vetoes"] += int(bool(row.get("hallucination_veto")))
|
||||
|
||||
distinct_cases = sorted({row.get("test_id") for row in valid})
|
||||
systems = sorted({row.get("system") for row in valid})
|
||||
layers = sorted({row.get("layer") for row in valid})
|
||||
complete = (
|
||||
not errors
|
||||
and len(records) == 180
|
||||
and len(valid) == 180
|
||||
and len(distinct_cases) == 60
|
||||
and len(systems) == 3
|
||||
and layers == ["layer1", "layer2", "layer3"]
|
||||
and all(counter["records"] == 20 for counter in by_layer_system.values())
|
||||
and len(by_layer_system) == 9
|
||||
)
|
||||
report = {
|
||||
"schema_version": "1.0",
|
||||
"experiment": "7-3",
|
||||
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"evidence_lineage": {
|
||||
"source_file": str(args.source),
|
||||
"source_sha256": sha256(args.source),
|
||||
"source_experiment": source.get("experiment"),
|
||||
"source_generated_at_utc": source.get("generated_at_utc"),
|
||||
"transformation": (
|
||||
"Validation/index only: no API records, answers, scores, or verdicts were added, "
|
||||
"removed, or changed."
|
||||
),
|
||||
},
|
||||
"rubric_contract": {
|
||||
"dimensions": sorted(REQUIRED_DIMENSIONS),
|
||||
"scale": "1..4 with concrete reasoning and cited evidence",
|
||||
"hallucination": "independent hard veto",
|
||||
},
|
||||
"run_scope": {
|
||||
"distinct_test_cases": len(distinct_cases),
|
||||
"layers": layers,
|
||||
"systems": systems,
|
||||
"records_expected": 180,
|
||||
"records_validated": len(valid),
|
||||
"all_60_cases_covered": len(distinct_cases) == 60,
|
||||
"validation_scope": "full" if complete else "incomplete",
|
||||
},
|
||||
"summary": [
|
||||
{"layer": layer, "system": system, **dict(counter)}
|
||||
for (layer, system), counter in sorted(by_layer_system.items())
|
||||
],
|
||||
"errors": errors,
|
||||
"status": "complete" if complete else "incomplete",
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps({
|
||||
"status": report["status"],
|
||||
"records_validated": len(valid),
|
||||
"distinct_cases": len(distinct_cases),
|
||||
"errors": len(errors),
|
||||
"output": str(args.output),
|
||||
}, ensure_ascii=False))
|
||||
return 0 if complete else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Summarize calibration output: per-case calls/tokens/cost, projected to 60 cases."""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
path = sys.argv[1]
|
||||
data = json.load(open(path))
|
||||
records = data["records"]
|
||||
ok = [r for r in records if r["status"] == "ok"]
|
||||
err = [r for r in records if r["status"] == "error"]
|
||||
print(f"records={len(records)} ok={len(ok)} error={len(err)}")
|
||||
|
||||
tokens_by_cell_component = defaultdict(int)
|
||||
chat_in = defaultdict(int)
|
||||
chat_out = defaultdict(int)
|
||||
costs = defaultdict(float)
|
||||
unpriced_tokens = 0
|
||||
unpriced_requests = 0
|
||||
latencies = []
|
||||
|
||||
for r in ok:
|
||||
latencies.append(r["latency_ms"])
|
||||
unpriced_tokens += r["unpriced_tokens"] + r["fixed_query_unpriced_tokens"]
|
||||
unpriced_requests += r["unpriced_requests"] + r["fixed_query_unpriced_requests"]
|
||||
for cur, amt in r.get("cost_by_currency", {}).items():
|
||||
costs[cur] += amt
|
||||
for cur, amt in r.get("fixed_query_retrieval_cost_by_currency", {}).items():
|
||||
costs[cur] += amt
|
||||
# main+reranker+judge tokens are merged in input/output tokens;
|
||||
# fixed-query tokens are separate.
|
||||
chat_in[r["main_model"]] += r["input_tokens"]
|
||||
chat_out[r["main_model"]] += r["output_tokens"]
|
||||
|
||||
print("\nPer-case totals (one case = 24 cells + 12 fixed-query benchmarks):")
|
||||
print(f" primary input tokens by main model: {dict(chat_in)}")
|
||||
print(f" primary output tokens by main model: {dict(chat_out)}")
|
||||
print(f" fixed-query tokens: {sum(r['fixed_query_input_tokens'] + r['fixed_query_output_tokens'] for r in ok)}")
|
||||
print(f" cost by currency: {dict(costs)}")
|
||||
print(f" unpriced tokens: {unpriced_tokens}, unpriced requests: {unpriced_requests}")
|
||||
print(f" latency_ms sum over records: {sum(latencies):.0f} "
|
||||
f"(serial per-cell latency; per-case wall clock differs)")
|
||||
|
||||
print("\nProjected x60 cases:")
|
||||
for cur, amt in costs.items():
|
||||
print(f" {cur}: {amt * 60:.2f}")
|
||||
print(f" primary input tokens: {sum(chat_in.values()) * 60:,}")
|
||||
print(f" primary output tokens: {sum(chat_out.values()) * 60:,}")
|
||||
|
||||
# steps/tool calls distribution
|
||||
import statistics
|
||||
steps = [r["steps"] for r in ok]
|
||||
tools = [r["tool_calls"] for r in ok]
|
||||
print(f"\nsteps: mean={statistics.fmean(steps):.2f} max={max(steps)}; "
|
||||
f"tool_calls: mean={statistics.fmean(tools):.2f} max={max(tools)}")
|
||||
by_rr = defaultdict(list)
|
||||
for r in ok:
|
||||
by_rr[(r["reranker"], r["main_model"])].append(r["latency_ms"])
|
||||
for k, v in sorted(by_rr.items(), key=str):
|
||||
print(f" {k}: n={len(v)} mean latency {statistics.fmean(v)/1000:.1f}s")
|
||||
@@ -0,0 +1,143 @@
|
||||
# Full book matrix. Prices are dated provider list prices in the native
|
||||
# published currency. Never add an FX conversion without its own dated source.
|
||||
#
|
||||
# Backend substitutions recorded 2026-07-31 (acceptance is tied to equivalent
|
||||
# providers/models, not to one vendor's official API; every substitution is
|
||||
# documented here and in README.md):
|
||||
# - SiliconFlow is unfunded (HTTP 402 balance=0, key itself valid), so the
|
||||
# BGE-M3 embedding and the BGE cross-encoder reranker cannot run there.
|
||||
# BGE-M3 now runs as the identical model `baai/bge-m3` via OpenRouter.
|
||||
# No reachable cross-encoder reranker remains (DashScope gte-rerank returns
|
||||
# 403 AccessDenied on this account, and the mainland endpoint rejects the
|
||||
# international key), so the cross-encoder matrix slot is honestly replaced
|
||||
# by a second LLM reranker, `doubao-semantic`, keeping three distinct
|
||||
# reranking strategies (none / Kimi-LLM / Doubao-LLM).
|
||||
# - The direct OpenAI account has zero credits (HTTP 429), so the OpenAI
|
||||
# embedding runs as the identical model `openai/text-embedding-3-small`
|
||||
# via OpenRouter.
|
||||
# - Doubao embeddings on Ark require a console-provisioned endpoint id; every
|
||||
# public model name returns 404 InvalidEndpointOrModel on this account.
|
||||
# The slot is honestly replaced by `qwen/qwen3-embedding-8b` via OpenRouter
|
||||
# (Alibaba Qwen3-Embedding-8B, the closest Chinese-provider multilingual
|
||||
# embedding substitute).
|
||||
chat_models:
|
||||
kimi:
|
||||
model: kimi-k2.5
|
||||
base_url: https://api.moonshot.cn/v1
|
||||
api_key_env: KIMI_API_KEY
|
||||
disable_thinking: true
|
||||
temperature: 0.6
|
||||
pricing:
|
||||
currency: CNY
|
||||
as_of_date: "2026-07-29"
|
||||
source_url: https://platform.kimi.com/docs/pricing/chat-k25
|
||||
input_per_million: 4.00
|
||||
cached_input_per_million: 0.70
|
||||
output_per_million: 21.00
|
||||
source_note: Published Kimi K2.5 list price; input rate is cache-miss/uncached.
|
||||
doubao:
|
||||
model: doubao-seed-1-6-250615
|
||||
base_url: https://ark.cn-beijing.volces.com/api/v3
|
||||
api_key_env: ARK_API_KEY
|
||||
pricing:
|
||||
currency: CNY
|
||||
as_of_date: "2026-07-31"
|
||||
source_url: https://www.volcengine.com/docs/82379/1544106
|
||||
input_per_million: 0.80
|
||||
output_per_million: 8.00
|
||||
source_note: Published Doubao-Seed-1.6 list price for the 0-32K input-length
|
||||
range (input 0.8 CNY/M, output 8 CNY/M). All matrix prompts are below 32K.
|
||||
openai-mini:
|
||||
model: gpt-4.1-mini
|
||||
base_url: https://api.openai.com/v1
|
||||
api_key_env: OPENAI_API_KEY
|
||||
|
||||
embeddings:
|
||||
bge-m3:
|
||||
# Same BAAI/bge-m3 model as the book matrix; provider substituted
|
||||
# SiliconFlow -> OpenRouter on 2026-07-31 (SiliconFlow balance is 0).
|
||||
model: baai/bge-m3
|
||||
base_url: https://openrouter.ai/api/v1
|
||||
api_key_env: OPENROUTER_API_KEY
|
||||
pricing:
|
||||
currency: USD
|
||||
as_of_date: "2026-07-31"
|
||||
source_url: https://openrouter.ai/baai/bge-m3
|
||||
input_per_million: 0.01
|
||||
source_note: OpenRouter catalog price for baai/bge-m3; matches the per-token
|
||||
cost returned by the /api/v1/embeddings/models listing and live usage.
|
||||
openai-small:
|
||||
# Same text-embedding-3-small model as the book matrix; provider substituted
|
||||
# direct OpenAI -> OpenRouter on 2026-07-31 (OpenAI account has no credits).
|
||||
model: openai/text-embedding-3-small
|
||||
base_url: https://openrouter.ai/api/v1
|
||||
api_key_env: OPENROUTER_API_KEY
|
||||
pricing:
|
||||
currency: USD
|
||||
as_of_date: "2026-07-31"
|
||||
source_url: https://openrouter.ai/openai/text-embedding-3-small
|
||||
input_per_million: 0.02
|
||||
source_note: OpenRouter catalog price, identical to the published
|
||||
text-embedding-3-small list price.
|
||||
qwen3:
|
||||
# Substitution for the unavailable Doubao embedding slot (Ark embeddings
|
||||
# need a provisioned endpoint id; all public model names 404 on this
|
||||
# account). Qwen3-Embedding-8B is the closest Chinese-provider multilingual
|
||||
# embedding reachable via OpenRouter.
|
||||
model: qwen/qwen3-embedding-8b
|
||||
base_url: https://openrouter.ai/api/v1
|
||||
api_key_env: OPENROUTER_API_KEY
|
||||
pricing:
|
||||
currency: USD
|
||||
as_of_date: "2026-07-31"
|
||||
source_url: https://openrouter.ai/qwen/qwen3-embedding-8b
|
||||
input_per_million: 0.01
|
||||
source_note: OpenRouter catalog price for qwen/qwen3-embedding-8b.
|
||||
mistral:
|
||||
model: mistral-embed
|
||||
base_url: https://api.mistral.ai/v1
|
||||
api_key_env: MISTRAL_API_KEY
|
||||
pricing:
|
||||
currency: USD
|
||||
as_of_date: "2026-07-29"
|
||||
source_url: https://mistral.ai/pricing/api/
|
||||
input_per_million: 0.10
|
||||
source_note: Published Mistral Embed list price.
|
||||
|
||||
rerankers:
|
||||
none:
|
||||
type: none
|
||||
doubao-semantic:
|
||||
# Substitution for the unavailable BGE cross-encoder slot (SiliconFlow
|
||||
# balance 0; DashScope gte-rerank 403 AccessDenied on this account).
|
||||
# A second LLM reranker on a different model keeps three distinct
|
||||
# reranking strategies without pretending a cross-encoder ran.
|
||||
type: llm
|
||||
chat_model: doubao
|
||||
kimi-semantic:
|
||||
type: llm
|
||||
chat_model: kimi
|
||||
|
||||
judge:
|
||||
evaluator: kimi
|
||||
model: kimi-k2.5
|
||||
|
||||
experiment_7_4:
|
||||
main_model: kimi
|
||||
embedding: mistral
|
||||
reranker: kimi-semantic
|
||||
rounds_per_chunk: 6
|
||||
overlap: 2
|
||||
|
||||
experiment_7_11:
|
||||
# The named adapters cover the BGE-M3/OpenAI/Mistral comparison from the
|
||||
# book plus the documented Qwen3 substitution for the unreachable Doubao
|
||||
# embedding endpoint; rerankers compare none / Doubao-LLM / Kimi-LLM after
|
||||
# the documented cross-encoder substitution.
|
||||
embeddings: [bge-m3, openai-small, qwen3, mistral]
|
||||
rerankers: [none, doubao-semantic, kimi-semantic]
|
||||
main_models: [kimi, doubao]
|
||||
retrieval_judge_model: kimi
|
||||
max_search_rounds: 3
|
||||
rounds_per_chunk: 6
|
||||
overlap: 2
|
||||
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add complete 7-3 rubric evidence to saved 7-4/7-11 case checkpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
from experiment import EVAL_DIR, UserMemoryEvaluationFramework
|
||||
from evaluator import LLMEvaluator
|
||||
|
||||
|
||||
REQUIRED_DIMENSIONS = {"precision", "recall", "reasoning", "proactivity"}
|
||||
|
||||
|
||||
def enrich_case(path: Path, evaluator_type: str, model: str, test_cases_dir: Path) -> Dict[str, Any]:
|
||||
framework = UserMemoryEvaluationFramework(str(test_cases_dir))
|
||||
evaluator = LLMEvaluator(evaluator_type, model=model)
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
updated = 0
|
||||
errors = []
|
||||
for row in payload.get("records", []):
|
||||
if row.get("status") != "ok":
|
||||
continue
|
||||
if set(row.get("rubric_details", {})) == REQUIRED_DIMENSIONS and row.get("hallucination_detail"):
|
||||
continue
|
||||
test_case = framework.get_test_case(row["test_id"])
|
||||
result = evaluator.evaluate(test_case, row["answer"])
|
||||
if set(result.dimensions) != REQUIRED_DIMENSIONS or result.hallucination is None:
|
||||
errors.append({"system": row["system"], "reason": result.reasoning})
|
||||
continue
|
||||
row["reward"] = result.reward
|
||||
row["success"] = bool(result.passed)
|
||||
row["rubric_dimensions"] = {name: value.score for name, value in result.dimensions.items()}
|
||||
row["rubric_details"] = {
|
||||
name: value.model_dump(mode="json") for name, value in result.dimensions.items()
|
||||
}
|
||||
row["hallucination_veto"] = result.veto_applied
|
||||
row["hallucination_detail"] = result.hallucination.model_dump(mode="json")
|
||||
row["evaluation_reasoning"] = result.reasoning
|
||||
row["evaluation_suggestions"] = result.suggestions
|
||||
updated += 1
|
||||
if not errors:
|
||||
payload["rubric_enrichment"] = {
|
||||
"completed_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"evaluator": evaluator_type,
|
||||
"model": model,
|
||||
"records_updated": updated,
|
||||
"all_ok_records_have_full_rubric": all(
|
||||
row.get("status") != "ok"
|
||||
or (
|
||||
set(row.get("rubric_details", {})) == REQUIRED_DIMENSIONS
|
||||
and row.get("hallucination_detail") is not None
|
||||
)
|
||||
for row in payload.get("records", [])
|
||||
),
|
||||
}
|
||||
temporary = path.with_suffix(path.suffix + ".tmp")
|
||||
temporary.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
temporary.replace(path)
|
||||
return {"path": str(path), "updated": updated, "errors": errors}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("checkpoint_dir", type=Path)
|
||||
parser.add_argument("--workers", type=int, default=4)
|
||||
parser.add_argument("--evaluator", default="kimi")
|
||||
parser.add_argument("--model", default="kimi-k2.5")
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
paths = sorted(args.checkpoint_dir.glob("*.json"))
|
||||
results = []
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as pool:
|
||||
futures = {
|
||||
pool.submit(enrich_case, path, args.evaluator, args.model, EVAL_DIR / "test_cases"): path
|
||||
for path in paths
|
||||
}
|
||||
for index, future in enumerate(as_completed(futures), 1):
|
||||
result = future.result()
|
||||
results.append(result)
|
||||
print(f"[{index}/{len(paths)}] {Path(result['path']).stem}: +{result['updated']} rubric records, errors={len(result['errors'])}")
|
||||
report = {
|
||||
"schema_version": "1.0",
|
||||
"experiment": "7-3 rubric enrichment",
|
||||
"generated_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"checkpoint_dir": str(args.checkpoint_dir),
|
||||
"checkpoint_count": len(paths),
|
||||
"records_updated": sum(row["updated"] for row in results),
|
||||
"errors": [error for row in results for error in row["errors"]],
|
||||
"complete": not any(row["errors"] for row in results),
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(report, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"Wrote enrichment audit to {args.output}; complete={report['complete']}")
|
||||
return 0 if report["complete"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
OPENAI_API_KEY=your_openai_key
|
||||
KIMI_API_KEY=your_moonshot_key
|
||||
SILICONFLOW_API_KEY=your_siliconflow_key
|
||||
ARK_API_KEY=your_volcengine_ark_key
|
||||
MISTRAL_API_KEY=your_mistral_key
|
||||
# OpenRouter carries the BGE-M3/OpenAI/Qwen3 embeddings after the documented
|
||||
# 2026-07-31 substitutions (see default_config.yaml header and README.md).
|
||||
OPENROUTER_API_KEY=your_openrouter_key
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,64 @@
|
||||
# Known-working validation subset on the companion development account.
|
||||
# This never substitutes for default_config.yaml's full BGE/OpenAI/Doubao sweep.
|
||||
chat_models:
|
||||
kimi:
|
||||
model: kimi-k2.5
|
||||
base_url: https://api.moonshot.cn/v1
|
||||
api_key_env: KIMI_API_KEY
|
||||
disable_thinking: true
|
||||
temperature: 0.6
|
||||
pricing:
|
||||
currency: CNY
|
||||
as_of_date: "2026-07-29"
|
||||
source_url: https://platform.kimi.com/docs/pricing/chat-k25
|
||||
input_per_million: 4.00
|
||||
cached_input_per_million: 0.70
|
||||
output_per_million: 21.00
|
||||
source_note: Published Kimi K2.5 list price; input rate is cache-miss/uncached.
|
||||
doubao:
|
||||
model: doubao-seed-1-6-250615
|
||||
base_url: https://ark.cn-beijing.volces.com/api/v3
|
||||
api_key_env: ARK_API_KEY
|
||||
|
||||
embeddings:
|
||||
mistral:
|
||||
model: mistral-embed
|
||||
base_url: https://api.mistral.ai/v1
|
||||
api_key_env: MISTRAL_API_KEY
|
||||
pricing:
|
||||
currency: USD
|
||||
as_of_date: "2026-07-29"
|
||||
source_url: https://mistral.ai/pricing/api/
|
||||
input_per_million: 0.10
|
||||
source_note: Published Mistral Embed list price.
|
||||
codestral:
|
||||
model: codestral-embed
|
||||
base_url: https://api.mistral.ai/v1
|
||||
api_key_env: MISTRAL_API_KEY
|
||||
|
||||
rerankers:
|
||||
none:
|
||||
type: none
|
||||
kimi-semantic:
|
||||
type: llm
|
||||
chat_model: kimi
|
||||
|
||||
judge:
|
||||
evaluator: kimi
|
||||
model: kimi-k2.5
|
||||
|
||||
experiment_7_4:
|
||||
main_model: kimi
|
||||
embedding: mistral
|
||||
reranker: kimi-semantic
|
||||
rounds_per_chunk: 6
|
||||
overlap: 2
|
||||
|
||||
experiment_7_11:
|
||||
embeddings: [mistral, codestral]
|
||||
rerankers: [none, kimi-semantic]
|
||||
main_models: [kimi, doubao]
|
||||
retrieval_judge_model: kimi
|
||||
max_search_rounds: 3
|
||||
rounds_per_chunk: 6
|
||||
overlap: 2
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Probe every backend required by a config without exposing credentials."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from experiment import (
|
||||
ChatBackend,
|
||||
Chunk,
|
||||
EmbeddingBackend,
|
||||
EndpointSpec,
|
||||
ExperimentRunner,
|
||||
execution_config_fingerprint,
|
||||
load_config,
|
||||
required_readiness_components,
|
||||
)
|
||||
|
||||
|
||||
def sanitized_error(exc: Exception, key_envs) -> str:
|
||||
message = f"{type(exc).__name__}: {exc}"
|
||||
for env_name in key_envs:
|
||||
secret = os.getenv(env_name, "")
|
||||
if secret:
|
||||
message = message.replace(secret, "<redacted>")
|
||||
return message[:1000]
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--config", type=Path, default=Path(__file__).with_name("default_config.yaml"))
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
args = parser.parse_args()
|
||||
config = load_config(args.config)
|
||||
key_envs = {
|
||||
data["api_key_env"]
|
||||
for section in ("chat_models", "embeddings")
|
||||
for data in config[section].values()
|
||||
} | {
|
||||
data["api_key_env"] for data in config["rerankers"].values() if data.get("api_key_env")
|
||||
}
|
||||
results = []
|
||||
|
||||
required_chat = {
|
||||
config["experiment_7_4"]["main_model"],
|
||||
config["experiment_7_11"]["retrieval_judge_model"],
|
||||
*config["experiment_7_11"]["main_models"],
|
||||
*[
|
||||
data["chat_model"]
|
||||
for name, data in config["rerankers"].items()
|
||||
if name in config["experiment_7_11"]["rerankers"] and data.get("type") == "llm"
|
||||
],
|
||||
}
|
||||
for name in sorted(required_chat):
|
||||
raw = config["chat_models"][name]
|
||||
row = {"component": "chat", "name": name, "model": raw.get("model"), "key_env": raw.get("api_key_env")}
|
||||
try:
|
||||
spec = EndpointSpec.from_dict({"name": name, **raw})
|
||||
turn = ChatBackend(spec).complete([{"role": "user", "content": "Reply exactly OK"}])
|
||||
row.update(status="ok", latency_ms=turn.latency_ms, key_present=True)
|
||||
except Exception as exc:
|
||||
row.update(status="error", error=sanitized_error(exc, key_envs), key_present=bool(os.getenv(raw.get("api_key_env", ""))))
|
||||
results.append(row)
|
||||
|
||||
required_embeddings = {
|
||||
config["experiment_7_4"]["embedding"], *config["experiment_7_11"]["embeddings"]
|
||||
}
|
||||
for name in sorted(required_embeddings):
|
||||
raw = config["embeddings"][name]
|
||||
row = {"component": "embedding", "name": name, "model": raw.get("model"), "key_env": raw.get("api_key_env")}
|
||||
try:
|
||||
spec = EndpointSpec.from_dict({"name": name, **raw})
|
||||
backend = EmbeddingBackend(spec)
|
||||
vector = backend.embed(["user memory retrieval backend probe"])[0]
|
||||
row.update(status="ok", dimensions=len(vector), latency_ms=backend.last_latency_ms, key_present=True)
|
||||
except Exception as exc:
|
||||
row.update(status="error", error=sanitized_error(exc, key_envs), key_present=bool(os.getenv(raw.get("api_key_env", ""))))
|
||||
results.append(row)
|
||||
|
||||
# Reuse the production factory so this verifies the same reranker code path.
|
||||
runner = object.__new__(ExperimentRunner)
|
||||
runner.config = config
|
||||
runner.endpoint_specs = {
|
||||
name: EndpointSpec.from_dict({"name": name, **data}) for name, data in config["chat_models"].items()
|
||||
}
|
||||
chunks = [Chunk("a", "probe", "checking account number 123", 1, 1), Chunk("b", "probe", "weather", 2, 2)]
|
||||
required_rerankers = {
|
||||
config["experiment_7_4"]["reranker"], *config["experiment_7_11"]["rerankers"]
|
||||
}
|
||||
for name in sorted(required_rerankers):
|
||||
row = {"component": "reranker", "name": name}
|
||||
try:
|
||||
backend = runner._reranker(name)
|
||||
ranked = backend.rerank("checking account", chunks, 2)
|
||||
row.update(status="ok", returned=len(ranked), latency_ms=backend.last_latency_ms)
|
||||
except Exception as exc:
|
||||
row.update(status="error", error=sanitized_error(exc, key_envs))
|
||||
results.append(row)
|
||||
|
||||
payload = {
|
||||
"schema_version": "2.0",
|
||||
"experiment": "7-4/7-11 provider readiness",
|
||||
"generated_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"config_file": str(args.config),
|
||||
"execution_config_fingerprint": execution_config_fingerprint(config, "7-11"),
|
||||
"required_components": [
|
||||
{"component": component, "name": name}
|
||||
for component, name in sorted(required_readiness_components(config))
|
||||
],
|
||||
"credentials_redacted": True,
|
||||
"probes": results,
|
||||
"summary": {
|
||||
"ok": sum(row["status"] == "ok" for row in results),
|
||||
"error": sum(row["status"] == "error" for row in results),
|
||||
"all_required_backends_ready": all(row["status"] == "ok" for row in results),
|
||||
},
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(json.dumps(payload["summary"]))
|
||||
print(f"Wrote sanitized backend readiness evidence to {args.output}")
|
||||
return 0 if payload["summary"]["all_required_backends_ready"] else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,181 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Exploratory backend probes for Experiment 7-11 readiness (2026-07-31).
|
||||
|
||||
Probes candidate substitutions with minimal real calls (1-line embed, 1-token
|
||||
chat, tiny rerank) and records sanitized, credential-free receipts. Secrets are
|
||||
read from the environment only; every recorded error string is scrubbed of any
|
||||
environment-held credential before being written.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from openai import OpenAI
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
OUT = HERE / "results" / "candidate_backend_probes_20260731.json"
|
||||
|
||||
KEY_ENVS = [
|
||||
"KIMI_API_KEY", "MOONSHOT_API_KEY", "ARK_API_KEY", "DASHSCOPE_API_KEY",
|
||||
"SILICONFLOW_API_KEY", "MISTRAL_API_KEY", "GEMINI_API_KEY",
|
||||
"OPENROUTER_API_KEY", "OPENAI_API_KEY",
|
||||
]
|
||||
|
||||
|
||||
def scrub(text: str) -> str:
|
||||
for env in KEY_ENVS:
|
||||
secret = os.getenv(env, "")
|
||||
if secret:
|
||||
text = text.replace(secret, "<redacted>")
|
||||
return text[:1500]
|
||||
|
||||
|
||||
def embed_probe(name, base_url, key_env, model, **extra):
|
||||
row = {"component": "embedding", "name": name, "model": model,
|
||||
"base_url": base_url, "key_env": key_env,
|
||||
"key_present": bool(os.getenv(key_env, ""))}
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
client = OpenAI(api_key=os.environ[key_env], base_url=base_url, timeout=60)
|
||||
kwargs = {"model": model, "input": ["user memory retrieval backend probe"]}
|
||||
kwargs.update(extra)
|
||||
resp = client.embeddings.create(**kwargs)
|
||||
row.update(status="ok", dimensions=len(resp.data[0].embedding),
|
||||
latency_ms=round((time.perf_counter() - started) * 1000, 1),
|
||||
usage=resp.usage.model_dump() if resp.usage else None)
|
||||
except Exception as exc: # noqa: BLE001 - receipts must capture any failure
|
||||
row.update(status="error", latency_ms=round((time.perf_counter() - started) * 1000, 1),
|
||||
error=scrub(f"{type(exc).__name__}: {exc}"))
|
||||
return row
|
||||
|
||||
|
||||
def chat_probe(name, base_url, key_env, model, max_tokens=1, **extra):
|
||||
row = {"component": "chat", "name": name, "model": model,
|
||||
"base_url": base_url, "key_env": key_env,
|
||||
"key_present": bool(os.getenv(key_env, ""))}
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
client = OpenAI(api_key=os.environ[key_env], base_url=base_url, timeout=60)
|
||||
kwargs = {"model": model,
|
||||
"messages": [{"role": "user", "content": "Reply exactly OK"}],
|
||||
"max_tokens": max_tokens}
|
||||
kwargs.update(extra)
|
||||
resp = client.chat.completions.create(**kwargs)
|
||||
row.update(status="ok", content=(resp.choices[0].message.content or "")[:40],
|
||||
latency_ms=round((time.perf_counter() - started) * 1000, 1),
|
||||
usage=resp.usage.model_dump() if resp.usage else None)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
row.update(status="error", latency_ms=round((time.perf_counter() - started) * 1000, 1),
|
||||
error=scrub(f"{type(exc).__name__}: {exc}"))
|
||||
return row
|
||||
|
||||
|
||||
def http_probe(name, method, url, key_env, payload=None):
|
||||
row = {"component": "http", "name": name, "url": url, "key_env": key_env,
|
||||
"key_present": bool(os.getenv(key_env, ""))}
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
headers = {"Authorization": f"Bearer {os.environ[key_env]}",
|
||||
"Content-Type": "application/json"}
|
||||
resp = requests.request(method, url, headers=headers, json=payload, timeout=60)
|
||||
row.update(status="ok" if resp.ok else "error", http_status=resp.status_code,
|
||||
latency_ms=round((time.perf_counter() - started) * 1000, 1),
|
||||
body=scrub(resp.text))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
row.update(status="error", latency_ms=round((time.perf_counter() - started) * 1000, 1),
|
||||
error=scrub(f"{type(exc).__name__}: {exc}"))
|
||||
return row
|
||||
|
||||
|
||||
def main():
|
||||
results = []
|
||||
|
||||
# --- SiliconFlow: reproduce and diagnose the 401 -------------------------
|
||||
results.append(embed_probe(
|
||||
"siliconflow-bge-m3", "https://api.siliconflow.cn/v1",
|
||||
"SILICONFLOW_API_KEY", "BAAI/bge-m3"))
|
||||
results.append(http_probe(
|
||||
"siliconflow-rerank-v2-m3", "POST", "https://api.siliconflow.cn/v1/rerank",
|
||||
"SILICONFLOW_API_KEY",
|
||||
{"model": "BAAI/bge-reranker-v2-m3", "query": "checking account",
|
||||
"documents": ["checking account number 123", "weather"], "top_n": 2,
|
||||
"return_documents": False}))
|
||||
# Account-level diagnosis: is the key itself dead or just the model/balance?
|
||||
results.append(http_probe(
|
||||
"siliconflow-user-info", "GET", "https://api.siliconflow.cn/v1/user/info",
|
||||
"SILICONFLOW_API_KEY"))
|
||||
|
||||
# --- OpenAI direct: confirm quota state ----------------------------------
|
||||
results.append(embed_probe(
|
||||
"openai-text-embedding-3-small", "https://api.openai.com/v1",
|
||||
"OPENAI_API_KEY", "text-embedding-3-small"))
|
||||
|
||||
# --- OpenRouter: OpenAI embedding pass-through + BGE-M3 availability -----
|
||||
results.append(embed_probe(
|
||||
"openrouter-openai-text-embedding-3-small", "https://openrouter.ai/api/v1",
|
||||
"OPENROUTER_API_KEY", "openai/text-embedding-3-small"))
|
||||
results.append(embed_probe(
|
||||
"openrouter-baai-bge-m3", "https://openrouter.ai/api/v1",
|
||||
"OPENROUTER_API_KEY", "BAAI/bge-m3"))
|
||||
|
||||
# --- ARK/Doubao: try public model-name embedding access ------------------
|
||||
for model in ("doubao-embedding-large-text-250515",
|
||||
"doubao-embedding-large-text-240915",
|
||||
"doubao-embedding-text-240715"):
|
||||
results.append(embed_probe(
|
||||
f"ark-{model}", "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"ARK_API_KEY", model))
|
||||
|
||||
# --- DashScope (Alibaba): documented substitutes -------------------------
|
||||
results.append(embed_probe(
|
||||
"dashscope-text-embedding-v4", "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||||
"DASHSCOPE_API_KEY", "text-embedding-v4"))
|
||||
results.append(http_probe(
|
||||
"dashscope-gte-rerank-v2", "POST",
|
||||
"https://dashscope.aliyuncs.com/api/v1/services/rerank/text-rerank/text-rerank",
|
||||
"DASHSCOPE_API_KEY",
|
||||
{"model": "gte-rerank-v2",
|
||||
"input": {"query": "checking account",
|
||||
"documents": ["checking account number 123", "weather"]},
|
||||
"parameters": {"top_n": 2, "return_documents": False}}))
|
||||
|
||||
# --- Known-good controls --------------------------------------------------
|
||||
results.append(embed_probe(
|
||||
"mistral-embed", "https://api.mistral.ai/v1",
|
||||
"MISTRAL_API_KEY", "mistral-embed"))
|
||||
results.append(chat_probe(
|
||||
"kimi-k2.5", "https://api.moonshot.cn/v1", "KIMI_API_KEY", "kimi-k2.5",
|
||||
max_tokens=16, extra_body={"thinking": {"type": "disabled"}}, temperature=0.6))
|
||||
results.append(chat_probe(
|
||||
"doubao-seed-1-6-250615", "https://ark.cn-beijing.volces.com/api/v3",
|
||||
"ARK_API_KEY", "doubao-seed-1-6-250615", max_tokens=16))
|
||||
|
||||
# --- Gemini embedding (last-resort fallback) ------------------------------
|
||||
results.append(embed_probe(
|
||||
"gemini-embedding-001", "https://generativelanguage.googleapis.com/v1beta/openai/",
|
||||
"GEMINI_API_KEY", "gemini-embedding-001"))
|
||||
|
||||
payload = {
|
||||
"schema_version": "1.0",
|
||||
"purpose": "Experiment 7-11 readiness substitution probes",
|
||||
"generated_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"credentials_redacted": True,
|
||||
"probes": results,
|
||||
"summary": {
|
||||
"ok": sum(r["status"] == "ok" for r in results),
|
||||
"error": sum(r["status"] == "error" for r in results),
|
||||
},
|
||||
}
|
||||
OUT.parent.mkdir(parents=True, exist_ok=True)
|
||||
OUT.write_text(json.dumps(payload, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
for row in results:
|
||||
print(f"{row['status']:5s} {row['name']}")
|
||||
print(json.dumps(payload["summary"]))
|
||||
print(f"Wrote {OUT}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,116 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rebuild aggregate/report metadata from saved live trajectory records.
|
||||
|
||||
This performs no model calls and never invents records. It is useful when report
|
||||
logic changes (for example, the stricter core-dimension success gate) while the
|
||||
underlying expensive API answers and judge dimension scores remain valid.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from experiment import RunRecord, load_config, reprice_legacy_64_records, save_report
|
||||
|
||||
|
||||
CORE_SUCCESS_DIMENSIONS = ("precision", "recall", "reasoning")
|
||||
ACCOUNTING_FIELDS = {
|
||||
"cost_usd",
|
||||
"cost_by_currency",
|
||||
"unpriced_tokens",
|
||||
"cached_input_tokens",
|
||||
"unpriced_requests",
|
||||
"cost_accounting",
|
||||
}
|
||||
|
||||
|
||||
def canonical_non_accounting_hash(records: list[RunRecord]) -> str:
|
||||
payload = [
|
||||
{
|
||||
key: value
|
||||
for key, value in vars(record).items()
|
||||
if key not in ACCOUNTING_FIELDS
|
||||
}
|
||||
for record in records
|
||||
]
|
||||
encoded = json.dumps(
|
||||
payload,
|
||||
sort_keys=True,
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
return hashlib.sha256(encoded).hexdigest()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("--config", type=Path, default=Path(__file__).with_name("live_config.yaml"))
|
||||
parser.add_argument(
|
||||
"--reprice-legacy-7-4",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Cover legacy Kimi usage with dated native-CNY list prices. Legacy cached-token counts "
|
||||
"were not saved, so all unpriced Kimi input uses the uncached rate."
|
||||
),
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
source = json.loads(args.source.read_text(encoding="utf-8"))
|
||||
records = [RunRecord(**row) for row in source.get("records", [])]
|
||||
non_accounting_hash_before = canonical_non_accounting_hash(records)
|
||||
config = load_config(args.config)
|
||||
repricing = None
|
||||
if args.reprice_legacy_7_4:
|
||||
if source.get("experiment") != "7-4":
|
||||
parser.error("--reprice-legacy-7-4 requires an Experiment 7-4 source")
|
||||
repricing = reprice_legacy_64_records(
|
||||
records,
|
||||
config,
|
||||
source_generated_at_utc=source.get("generated_at_utc"),
|
||||
)
|
||||
for record in records:
|
||||
if record.status != "ok":
|
||||
record.success = False
|
||||
continue
|
||||
record.success = (
|
||||
not record.hallucination_veto
|
||||
and all(record.rubric_dimensions.get(name, 0) >= 3 for name in CORE_SUCCESS_DIMENSIONS)
|
||||
)
|
||||
non_accounting_hash_after = canonical_non_accounting_hash(records)
|
||||
if non_accounting_hash_before != non_accounting_hash_after:
|
||||
raise RuntimeError(
|
||||
"canonical non-accounting record hash changed; refusing to write a derived report"
|
||||
)
|
||||
save_report(args.output, source["experiment"], records, config)
|
||||
rebuilt = json.loads(args.output.read_text(encoding="utf-8"))
|
||||
rebuilt["evidence_lineage"] = {
|
||||
"source_file": str(args.source),
|
||||
"source_api_generated_at_utc": source.get("generated_at_utc"),
|
||||
"report_rebuilt_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
||||
"transformation": (
|
||||
"No API records were added or removed. Aggregates/run-scope metadata were rebuilt and "
|
||||
"success was recomputed from saved rubric dimensions using the documented >=3 core gate. "
|
||||
+ (
|
||||
"Legacy unpriced Kimi usage was covered using dated native-CNY list prices; all legacy "
|
||||
"input was conservatively treated as uncached because cached-token counts were not saved."
|
||||
if repricing else "No cost repricing was requested."
|
||||
)
|
||||
),
|
||||
"repricing": repricing,
|
||||
"trajectory_record_count_preserved": len(records),
|
||||
"canonical_non_accounting_sha256_before": non_accounting_hash_before,
|
||||
"canonical_non_accounting_sha256_after": non_accounting_hash_after,
|
||||
"canonical_non_accounting_records_preserved": True,
|
||||
"canonical_hash_excluded_fields": sorted(ACCOUNTING_FIELDS),
|
||||
}
|
||||
args.output.write_text(json.dumps(rebuilt, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(f"Rebuilt {len(records)} saved trajectories into {args.output}; no model calls made")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,8 @@
|
||||
openai>=1.0
|
||||
pydantic>=2.0
|
||||
python-dotenv>=1.0
|
||||
pyyaml>=6.0
|
||||
requests>=2.31
|
||||
rich>=13.0
|
||||
tenacity>=8.0
|
||||
pytest>=8.0
|
||||
+6289
File diff suppressed because it is too large
Load Diff
+6421
File diff suppressed because it is too large
Load Diff
+6437
File diff suppressed because it is too large
Load Diff
+6475
File diff suppressed because it is too large
Load Diff
+6220
File diff suppressed because it is too large
Load Diff
+6301
File diff suppressed because it is too large
Load Diff
+6403
File diff suppressed because it is too large
Load Diff
+6503
File diff suppressed because it is too large
Load Diff
+6347
File diff suppressed because it is too large
Load Diff
+6343
File diff suppressed because it is too large
Load Diff
+6473
File diff suppressed because it is too large
Load Diff
+6479
File diff suppressed because it is too large
Load Diff
+6564
File diff suppressed because it is too large
Load Diff
+6606
File diff suppressed because it is too large
Load Diff
+6825
File diff suppressed because it is too large
Load Diff
+6350
File diff suppressed because it is too large
Load Diff
+6746
File diff suppressed because it is too large
Load Diff
+6445
File diff suppressed because it is too large
Load Diff
+6481
File diff suppressed because it is too large
Load Diff
+6583
File diff suppressed because it is too large
Load Diff
+6491
File diff suppressed because it is too large
Load Diff
+6793
File diff suppressed because it is too large
Load Diff
+6702
File diff suppressed because it is too large
Load Diff
+6718
File diff suppressed because it is too large
Load Diff
+6667
File diff suppressed because it is too large
Load Diff
+6628
File diff suppressed because it is too large
Load Diff
+6567
File diff suppressed because it is too large
Load Diff
+6619
File diff suppressed because it is too large
Load Diff
+6656
File diff suppressed because it is too large
Load Diff
+6689
File diff suppressed because it is too large
Load Diff
+6834
File diff suppressed because it is too large
Load Diff
+6603
File diff suppressed because it is too large
Load Diff
+6682
File diff suppressed because it is too large
Load Diff
+6824
File diff suppressed because it is too large
Load Diff
+6774
File diff suppressed because it is too large
Load Diff
+6844
File diff suppressed because it is too large
Load Diff
+6773
File diff suppressed because it is too large
Load Diff
+6714
File diff suppressed because it is too large
Load Diff
+6857
File diff suppressed because it is too large
Load Diff
+6728
File diff suppressed because it is too large
Load Diff
+6564
File diff suppressed because it is too large
Load Diff
+6695
File diff suppressed because it is too large
Load Diff
+7029
File diff suppressed because it is too large
Load Diff
+7027
File diff suppressed because it is too large
Load Diff
+7865
File diff suppressed because it is too large
Load Diff
+6917
File diff suppressed because it is too large
Load Diff
+6787
File diff suppressed because it is too large
Load Diff
+6908
File diff suppressed because it is too large
Load Diff
+6895
File diff suppressed because it is too large
Load Diff
+7475
File diff suppressed because it is too large
Load Diff
+7030
File diff suppressed because it is too large
Load Diff
+7341
File diff suppressed because it is too large
Load Diff
+6957
File diff suppressed because it is too large
Load Diff
+6837
File diff suppressed because it is too large
Load Diff
+6808
File diff suppressed because it is too large
Load Diff
+6825
File diff suppressed because it is too large
Load Diff
+6937
File diff suppressed because it is too large
Load Diff
+6839
File diff suppressed because it is too large
Load Diff
+6970
File diff suppressed because it is too large
Load Diff
+6924
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,312 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Restart-safe bounded-parallel runner for the full 60-case experiments."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
|
||||
from experiment import (
|
||||
EVAL_DIR,
|
||||
HERE,
|
||||
RunRecord,
|
||||
UserMemoryEvaluationFramework,
|
||||
execution_config_fingerprint,
|
||||
load_config,
|
||||
reprice_legacy_64_records,
|
||||
save_report,
|
||||
validate_readiness,
|
||||
)
|
||||
|
||||
|
||||
REQUIRED_RUBRIC_DIMENSIONS = {"precision", "recall", "reasoning", "proactivity"}
|
||||
|
||||
|
||||
Cell = Tuple[str, ...]
|
||||
|
||||
|
||||
def valid_checkpoint(
|
||||
path: Path,
|
||||
test_id: str,
|
||||
experiment: str,
|
||||
expected_records: int,
|
||||
required_ok_cells: Optional[Set[Cell]] = None,
|
||||
expected_cells: Optional[Set[Cell]] = None,
|
||||
expected_config_fingerprint: Optional[str] = None,
|
||||
) -> bool:
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return False
|
||||
scope = data.get("run_scope", {})
|
||||
records = data.get("records", [])
|
||||
if not (
|
||||
data.get("experiment") == experiment
|
||||
and scope.get("requested_test_ids") == [test_id]
|
||||
and len(records) == expected_records
|
||||
):
|
||||
return False
|
||||
if expected_config_fingerprint is not None:
|
||||
try:
|
||||
observed_fingerprint = execution_config_fingerprint(
|
||||
data["configuration"], experiment
|
||||
)
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return False
|
||||
if observed_fingerprint != expected_config_fingerprint:
|
||||
return False
|
||||
observed_cells: List[Cell] = []
|
||||
for row in records:
|
||||
if row.get("test_id") != test_id or row.get("experiment") != experiment:
|
||||
return False
|
||||
if row.get("status") == "ok":
|
||||
if set(row.get("rubric_details", {})) != REQUIRED_RUBRIC_DIMENSIONS:
|
||||
return False
|
||||
if row.get("hallucination_detail") is None:
|
||||
return False
|
||||
elif row.get("status") == "error":
|
||||
identity = (row.get("embedding"), row.get("reranker"), row.get("main_model"))
|
||||
if (
|
||||
experiment == "7-4"
|
||||
or not row.get("error")
|
||||
or required_ok_cells is None
|
||||
or identity in required_ok_cells
|
||||
):
|
||||
return False
|
||||
else:
|
||||
return False
|
||||
if experiment == "7-11":
|
||||
observed_cells.append((row.get("embedding"), row.get("reranker"), row.get("main_model")))
|
||||
else:
|
||||
observed_cells.append((row.get("system"),))
|
||||
if expected_cells is not None and (
|
||||
len(observed_cells) != len(set(observed_cells)) or set(observed_cells) != expected_cells
|
||||
):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def required_611_cells(config: Dict[str, Any], readiness: Optional[Dict[str, Any]]) -> Set[Cell]:
|
||||
"""Return matrix cells whose backends passed preflight and must complete live."""
|
||||
matrix = config["experiment_7_11"]
|
||||
blocked = {
|
||||
(row["component"], row["name"])
|
||||
for row in (readiness or {}).get("probes", [])
|
||||
if row.get("status") == "error"
|
||||
}
|
||||
return {
|
||||
(embedding, reranker, main_model)
|
||||
for embedding in matrix["embeddings"]
|
||||
for reranker in matrix["rerankers"]
|
||||
for main_model in matrix["main_models"]
|
||||
if ("embedding", embedding) not in blocked
|
||||
and ("reranker", reranker) not in blocked
|
||||
and ("chat", main_model) not in blocked
|
||||
}
|
||||
|
||||
|
||||
def run_case(
|
||||
experiment: str,
|
||||
config_path: Path,
|
||||
test_id: str,
|
||||
output: Path,
|
||||
readiness: Path | None = None,
|
||||
) -> Dict[str, Any]:
|
||||
command = [
|
||||
sys.executable,
|
||||
str(HERE / "experiment.py"),
|
||||
experiment,
|
||||
"--config",
|
||||
str(config_path),
|
||||
"--test-id",
|
||||
test_id,
|
||||
"--output",
|
||||
str(output),
|
||||
]
|
||||
if readiness:
|
||||
command.extend(["--readiness", str(readiness)])
|
||||
started = time.perf_counter()
|
||||
process = subprocess.run(command, cwd=HERE, capture_output=True, text=True)
|
||||
return {
|
||||
"test_id": test_id,
|
||||
"returncode": process.returncode,
|
||||
"elapsed_seconds": time.perf_counter() - started,
|
||||
"stdout": process.stdout[-2000:],
|
||||
"stderr": process.stderr[-4000:],
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("experiment", choices=["7-4", "7-11"])
|
||||
parser.add_argument("--config", type=Path, default=HERE / "default_config.yaml")
|
||||
parser.add_argument("--workers", type=int, default=4)
|
||||
parser.add_argument("--checkpoint-dir", type=Path)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--readiness", type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.workers < 1:
|
||||
parser.error("--workers must be at least 1")
|
||||
|
||||
config_path = args.config.resolve()
|
||||
config = load_config(config_path)
|
||||
readiness_data = (
|
||||
json.loads(args.readiness.resolve().read_text(encoding="utf-8"))
|
||||
if args.readiness else None
|
||||
)
|
||||
if args.experiment == "7-11":
|
||||
if readiness_data is None:
|
||||
parser.error("exact Experiment 7-11 requires --readiness from probe_backends.py")
|
||||
readiness_errors = validate_readiness(config, readiness_data)
|
||||
if readiness_errors:
|
||||
parser.error("invalid readiness evidence: " + "; ".join(readiness_errors))
|
||||
config["execution_readiness"] = {
|
||||
"source_file": str(args.readiness.resolve()),
|
||||
"generated_at_utc": readiness_data.get("generated_at_utc"),
|
||||
"execution_config_fingerprint": readiness_data.get("execution_config_fingerprint"),
|
||||
"all_required_backends_ready": readiness_data.get("summary", {}).get("all_required_backends_ready"),
|
||||
"validated": True,
|
||||
}
|
||||
if not readiness_data.get("summary", {}).get("all_required_backends_ready"):
|
||||
parser.error(
|
||||
"exact Experiment 7-11 campaign is blocked: every required real backend "
|
||||
"must pass probe_backends.py before launch"
|
||||
)
|
||||
framework = UserMemoryEvaluationFramework(str(EVAL_DIR / "test_cases"))
|
||||
test_ids = [case.test_id for case in framework.list_test_cases()]
|
||||
if len(test_ids) != 60:
|
||||
parser.error(f"full run requires exactly 60 loaded cases, found {len(test_ids)}")
|
||||
|
||||
if args.experiment == "7-4":
|
||||
expected_records = 3
|
||||
required_ok_cells = None
|
||||
all_matrix_cells = {
|
||||
("advanced_json_cards",),
|
||||
("rag",),
|
||||
("hybrid",),
|
||||
}
|
||||
else:
|
||||
matrix = config["experiment_7_11"]
|
||||
shape = (len(matrix["embeddings"]), len(matrix["rerankers"]), len(matrix["main_models"]))
|
||||
if shape != (4, 3, 2):
|
||||
parser.error(f"exact Experiment 7-11 requires a 4x3x2 matrix, found {shape}")
|
||||
expected_records = len(matrix["embeddings"]) * len(matrix["rerankers"]) * len(matrix["main_models"])
|
||||
required_ok_cells = required_611_cells(config, readiness_data)
|
||||
all_matrix_cells = {
|
||||
(embedding, reranker, main_model)
|
||||
for embedding in matrix["embeddings"]
|
||||
for reranker in matrix["rerankers"]
|
||||
for main_model in matrix["main_models"]
|
||||
}
|
||||
checkpoint_dir = args.checkpoint_dir or (
|
||||
HERE / "results" / "checkpoints" / args.experiment.replace("-", "_") / config_path.stem
|
||||
)
|
||||
checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
||||
expected_fingerprint = execution_config_fingerprint(config, args.experiment)
|
||||
|
||||
pending = []
|
||||
for test_id in test_ids:
|
||||
path = checkpoint_dir / f"{test_id}.json"
|
||||
if not valid_checkpoint(
|
||||
path,
|
||||
test_id,
|
||||
args.experiment,
|
||||
expected_records,
|
||||
required_ok_cells,
|
||||
all_matrix_cells,
|
||||
expected_fingerprint,
|
||||
):
|
||||
pending.append((test_id, path))
|
||||
print(f"Full {args.experiment}: {60 - len(pending)}/60 checkpoints reusable; {len(pending)} pending")
|
||||
|
||||
failures: List[Dict[str, Any]] = []
|
||||
with ThreadPoolExecutor(max_workers=args.workers) as pool:
|
||||
futures = {
|
||||
pool.submit(
|
||||
run_case,
|
||||
args.experiment,
|
||||
config_path,
|
||||
test_id,
|
||||
path,
|
||||
args.readiness.resolve() if args.readiness else None,
|
||||
): test_id
|
||||
for test_id, path in pending
|
||||
}
|
||||
done = 60 - len(pending)
|
||||
for future in as_completed(futures):
|
||||
result = future.result()
|
||||
done += 1
|
||||
if result["returncode"]:
|
||||
failures.append(result)
|
||||
print(f"[{done}/60] ERROR {result['test_id']} ({result['elapsed_seconds']:.1f}s)")
|
||||
else:
|
||||
print(f"[{done}/60] OK {result['test_id']} ({result['elapsed_seconds']:.1f}s)")
|
||||
|
||||
records: List[RunRecord] = []
|
||||
valid_case_ids = []
|
||||
for test_id in test_ids:
|
||||
path = checkpoint_dir / f"{test_id}.json"
|
||||
if not valid_checkpoint(
|
||||
path,
|
||||
test_id,
|
||||
args.experiment,
|
||||
expected_records,
|
||||
required_ok_cells,
|
||||
all_matrix_cells,
|
||||
expected_fingerprint,
|
||||
):
|
||||
continue
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
records.extend(RunRecord(**row) for row in data["records"])
|
||||
valid_case_ids.append(test_id)
|
||||
|
||||
repricing = None
|
||||
if args.experiment == "7-4":
|
||||
repricing = reprice_legacy_64_records(records, config)
|
||||
save_report(args.output, args.experiment, records, config)
|
||||
merged = json.loads(args.output.read_text(encoding="utf-8"))
|
||||
merged["full_run_orchestration"] = {
|
||||
"workers": args.workers,
|
||||
"checkpoint_dir": str(checkpoint_dir),
|
||||
"expected_case_count": 60,
|
||||
"expected_records_per_case": expected_records,
|
||||
"valid_checkpoint_case_count": len(valid_case_ids),
|
||||
"missing_case_ids": sorted(set(test_ids) - set(valid_case_ids)),
|
||||
"subprocess_failures": failures,
|
||||
"execution_config_fingerprint": expected_fingerprint,
|
||||
"legacy_7_4_repricing": repricing,
|
||||
}
|
||||
# A complete experiment requires exact case/cell coverage, real successful
|
||||
# trajectories, explicit readiness (7-11), and zero unpriced usage.
|
||||
complete = bool(merged["completion"]["evidence_complete"]) and not failures
|
||||
if failures:
|
||||
merged["completion"]["evidence_complete"] = False
|
||||
merged["completion"]["trajectory_matrix_complete"] = False
|
||||
merged["completion"]["status"] = "incomplete"
|
||||
merged["completion"]["blockers"].append({
|
||||
"code": "subprocess_failures",
|
||||
"message": f"{len(failures)} case subprocesses failed",
|
||||
})
|
||||
merged["status"] = "incomplete"
|
||||
merged["run_scope"]["full_60_case_suite_completed"] = complete
|
||||
merged["run_scope"]["validation_scope"] = (
|
||||
"full" if complete else "incomplete-full-suite"
|
||||
)
|
||||
args.output.write_text(json.dumps(merged, indent=2, ensure_ascii=False), encoding="utf-8")
|
||||
print(
|
||||
f"Merged {len(records)} records from {len(valid_case_ids)}/60 cases into {args.output}; "
|
||||
f"full completion={complete}"
|
||||
)
|
||||
if complete:
|
||||
return 0
|
||||
return 2 if merged["completion"]["status"] == "blocked" else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Re-judge only incomplete saved 7-3 rubric records without mutating 7-4.
|
||||
|
||||
The source campaign remains immutable. Each supplemental judgment records the
|
||||
source record identity and answer hash so the full 7-3 validator can join it
|
||||
without confusing it with a newly executed memory-system trajectory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import sys
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
HERE = Path(__file__).resolve().parent
|
||||
EVAL_DIR = HERE.parents[1] / "chapter3" / "user-memory-evaluation"
|
||||
sys.path.insert(0, str(EVAL_DIR))
|
||||
|
||||
from evaluator import LLMEvaluator # noqa: E402
|
||||
from framework import UserMemoryEvaluationFramework # noqa: E402
|
||||
|
||||
|
||||
REQUIRED_DIMENSIONS = {"precision", "recall", "reasoning", "proactivity"}
|
||||
|
||||
|
||||
def answer_hash(answer: str) -> str:
|
||||
return hashlib.sha256(answer.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def complete_rubric(row: dict) -> bool:
|
||||
dimensions = row.get("rubric_details") or {}
|
||||
hallucination = row.get("hallucination_detail")
|
||||
return (
|
||||
set(dimensions) == REQUIRED_DIMENSIONS
|
||||
and isinstance(hallucination, dict)
|
||||
and "detected" in hallucination
|
||||
and all(
|
||||
isinstance(detail, dict)
|
||||
and bool(detail.get("reasoning"))
|
||||
and bool(detail.get("evidence") or detail.get("boundary_case"))
|
||||
for detail in dimensions.values()
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--source",
|
||||
type=Path,
|
||||
default=HERE / "results" / "full_7_4_60_cases_costed.json",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
default=HERE / "results" / "full_7_3_missing_rubric_supplement.json",
|
||||
)
|
||||
parser.add_argument("--evaluator", default="kimi", choices=["kimi", "openai"])
|
||||
parser.add_argument("--model", default="kimi-k2.5")
|
||||
args = parser.parse_args()
|
||||
|
||||
source = json.loads(args.source.read_text(encoding="utf-8"))
|
||||
missing = [row for row in source.get("records", []) if not complete_rubric(row)]
|
||||
framework = UserMemoryEvaluationFramework(str(EVAL_DIR / "test_cases"))
|
||||
judge = LLMEvaluator(args.evaluator, model=args.model)
|
||||
supplements = []
|
||||
for row in missing:
|
||||
test_case = framework.get_test_case(row["test_id"])
|
||||
result = judge.evaluate(test_case, row["answer"])
|
||||
supplements.append({
|
||||
"test_id": row["test_id"],
|
||||
"system": row["system"],
|
||||
"layer": row["layer"],
|
||||
"answer_sha256": answer_hash(row["answer"]),
|
||||
"provider": args.evaluator,
|
||||
"model": args.model,
|
||||
"evaluation": result.model_dump(mode="json"),
|
||||
})
|
||||
print(f"Re-judged {row['test_id']} / {row['system']}")
|
||||
|
||||
complete = all(
|
||||
set(item["evaluation"].get("dimensions", {})) == REQUIRED_DIMENSIONS
|
||||
and item["evaluation"].get("hallucination") is not None
|
||||
and all(
|
||||
detail.get("reasoning") and (detail.get("evidence") or detail.get("boundary_case"))
|
||||
for detail in item["evaluation"]["dimensions"].values()
|
||||
)
|
||||
for item in supplements
|
||||
)
|
||||
report = {
|
||||
"schema_version": "1.0",
|
||||
"experiment": "7-3",
|
||||
"purpose": "supplement incomplete rubric evidence only; source trajectories are unchanged",
|
||||
"generated_at_utc": datetime.now(timezone.utc).isoformat(),
|
||||
"source_file": str(args.source),
|
||||
"missing_records_detected": len(missing),
|
||||
"supplements": supplements,
|
||||
"status": "complete" if complete else "incomplete",
|
||||
}
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps({
|
||||
"status": report["status"],
|
||||
"supplements": len(supplements),
|
||||
"output": str(args.output),
|
||||
}))
|
||||
return 0 if complete else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Offline tests for the experiment harness; live API evidence is stored separately."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from experiment import (
|
||||
AgentResult,
|
||||
CardBuilder,
|
||||
ChatTurn,
|
||||
Chunk,
|
||||
MemoryAgent,
|
||||
LLMReranker,
|
||||
NoReranker,
|
||||
ToolCall,
|
||||
TokenPricing,
|
||||
Usage,
|
||||
VectorMemoryIndex,
|
||||
aggregate,
|
||||
completion_assessment,
|
||||
conversation_chunks,
|
||||
interaction_analysis,
|
||||
pricing_coverage,
|
||||
retrieval_metrics,
|
||||
select_core_cards,
|
||||
)
|
||||
|
||||
|
||||
class FakeEmbedder:
|
||||
def __init__(self):
|
||||
self.last_usage = Usage()
|
||||
self.last_latency_ms = 0.1
|
||||
|
||||
def embed(self, texts):
|
||||
self.last_usage = Usage(input_tokens=len(texts), cost_usd=0.001 * len(texts))
|
||||
vectors = []
|
||||
for text in texts:
|
||||
lower = text.lower()
|
||||
vectors.append([float("checking" in lower), float("medical" in lower), 1.0])
|
||||
return vectors
|
||||
|
||||
|
||||
class FakeToolChat:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def complete(self, messages, tools=None, tool_choice=None):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
return ChatTurn(
|
||||
"",
|
||||
[ToolCall("call-1", "search_memory", {"query": "checking account"})],
|
||||
Usage(input_tokens=10, output_tokens=2, cost_usd=0.01),
|
||||
4.0,
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{"id": "call-1", "type": "function", "function": {"name": "search_memory", "arguments": '{"query":"checking account"}'}}],
|
||||
},
|
||||
)
|
||||
return ChatTurn(
|
||||
"The checking account is 4429853327.", [], Usage(input_tokens=20, output_tokens=8, cost_usd=0.02), 5.0,
|
||||
{"role": "assistant", "content": "The checking account is 4429853327."},
|
||||
)
|
||||
|
||||
|
||||
class FakeMultiSearchChat(FakeToolChat):
|
||||
def complete(self, messages, tools=None, tool_choice=None):
|
||||
self.calls += 1
|
||||
if self.calls <= 2:
|
||||
call_id = f"call-{self.calls}"
|
||||
query = "checking account" if self.calls == 1 else "routing number"
|
||||
return ChatTurn(
|
||||
"",
|
||||
[ToolCall(call_id, "search_memory", {"query": query})],
|
||||
Usage(input_tokens=5, output_tokens=2),
|
||||
1.0,
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": call_id,
|
||||
"type": "function",
|
||||
"function": {"name": "search_memory", "arguments": json.dumps({"query": query})},
|
||||
}],
|
||||
},
|
||||
)
|
||||
return ChatTurn("complete", [], Usage(input_tokens=5, output_tokens=1), 1.0,
|
||||
{"role": "assistant", "content": "complete"})
|
||||
|
||||
|
||||
def load_case(test_id="layer1_01_bank_account"):
|
||||
import sys
|
||||
|
||||
eval_dir = Path(__file__).resolve().parents[2] / "chapter3" / "user-memory-evaluation"
|
||||
if str(eval_dir) not in sys.path:
|
||||
sys.path.insert(0, str(eval_dir))
|
||||
from framework import UserMemoryEvaluationFramework
|
||||
|
||||
return UserMemoryEvaluationFramework(str(eval_dir / "test_cases")).get_test_case(test_id)
|
||||
|
||||
|
||||
def test_chunking_is_stable_and_preserves_source():
|
||||
case = load_case()
|
||||
first = conversation_chunks(case, rounds_per_chunk=8, overlap=2)
|
||||
second = conversation_chunks(case, rounds_per_chunk=8, overlap=2)
|
||||
assert [c.chunk_id for c in first] == [c.chunk_id for c in second]
|
||||
assert len(first) > 1
|
||||
assert all(c.conversation_id == "bank_setup_001" for c in first)
|
||||
assert "4429853327" in "\n".join(c.text for c in first)
|
||||
|
||||
|
||||
def test_rag_agent_executes_real_tool_loop_shape_and_tracks_metrics():
|
||||
chunks = [
|
||||
Chunk("a", "c1", "checking account 4429853327", 1, 1),
|
||||
Chunk("b", "c1", "medical appointment", 2, 2),
|
||||
]
|
||||
index = VectorMemoryIndex(chunks, FakeEmbedder())
|
||||
result = MemoryAgent(FakeToolChat()).rag("What is my checking account?", index, NoReranker())
|
||||
assert result.answer.endswith("4429853327.")
|
||||
assert result.steps == 2
|
||||
assert result.tool_calls == 1
|
||||
assert result.retrieved_chunks[0].chunk_id == "a"
|
||||
assert result.latency_ms > 9
|
||||
assert result.usage.cost_usd >= 0.031
|
||||
|
||||
|
||||
def test_611_agent_can_make_followup_searches_and_exposes_tool_efficiency():
|
||||
chunks = [Chunk("a", "c", "checking account routing number", 1, 1)]
|
||||
index = VectorMemoryIndex(chunks, FakeEmbedder())
|
||||
result = MemoryAgent(FakeMultiSearchChat()).rag(
|
||||
"account?", index, NoReranker(), allow_followup_searches=True, max_search_rounds=3
|
||||
)
|
||||
assert result.answer == "complete"
|
||||
assert result.steps == 3
|
||||
assert result.tool_calls == 2
|
||||
assert [event["event"] for event in result.trace] == ["search_memory", "search_memory", "answer"]
|
||||
|
||||
|
||||
def test_retrieval_metrics_use_source_selected_gold():
|
||||
retrieved = [Chunk("wrong", "c", "", 1, 1), Chunk("gold", "c", "", 2, 2)]
|
||||
hit, recall, mrr = retrieval_metrics(retrieved, ["gold", "also-gold"])
|
||||
assert hit == 1.0
|
||||
assert recall == 0.5
|
||||
assert mrr == 0.5
|
||||
|
||||
|
||||
def test_hybrid_resident_context_contains_only_explicit_core_cards():
|
||||
cards = [
|
||||
{"card_key": "identity", "memory_tier": "core"},
|
||||
{"card_key": "old_call_detail", "memory_tier": "supporting"},
|
||||
]
|
||||
assert [card["card_key"] for card in select_core_cards(cards, "t")] == ["identity"]
|
||||
|
||||
|
||||
def test_hybrid_requires_at_least_one_core_card():
|
||||
import pytest
|
||||
|
||||
with pytest.raises(RuntimeError, match="no core-tier cards"):
|
||||
select_core_cards([{"memory_tier": "supporting"}], "case-x")
|
||||
|
||||
|
||||
class FakeCardChat:
|
||||
def __init__(self):
|
||||
self.calls = 0
|
||||
|
||||
def complete(self, messages, tools=None, tool_choice=None, json_object=False):
|
||||
self.calls += 1
|
||||
if self.calls == 1:
|
||||
content = '{"cards": [{"category": "identity"}]}'
|
||||
elif self.calls == 2:
|
||||
content = "{malformed"
|
||||
else:
|
||||
content = json.dumps({
|
||||
"cards": [{
|
||||
"category": "identity",
|
||||
"card_key": "user",
|
||||
"backstory": "account setup",
|
||||
"date_created": "2025-01-01",
|
||||
"person": "user",
|
||||
"relationship": "self",
|
||||
"facts": {"name": "Alex"},
|
||||
"source_conversation_ids": ["c1"],
|
||||
"status": "current",
|
||||
"memory_tier": "core",
|
||||
}]
|
||||
})
|
||||
return ChatTurn(
|
||||
content, [], Usage(input_tokens=10, output_tokens=5), 2.0,
|
||||
{"role": "assistant", "content": content},
|
||||
)
|
||||
|
||||
|
||||
def test_card_builder_retries_parse_and_schema_failures_without_weakening_schema():
|
||||
chat = FakeCardChat()
|
||||
cards, usage, latency = CardBuilder(chat).build(load_case())
|
||||
assert chat.calls == 3
|
||||
assert cards[0]["memory_tier"] == "core"
|
||||
assert cards[0]["source_conversation_ids"] == ["c1"]
|
||||
assert usage.input_tokens == 30
|
||||
assert usage.output_tokens == 15
|
||||
assert latency == 6.0
|
||||
|
||||
|
||||
class FakeRerankerChat:
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
self.calls = 0
|
||||
|
||||
def complete(self, messages, tools=None, tool_choice=None, json_object=False):
|
||||
self.calls += 1
|
||||
self.messages.append(messages)
|
||||
content = '{"ranking": []}' if self.calls == 1 else '{"ranking": [{"index": 1, "score": 0.9}]}'
|
||||
return ChatTurn(
|
||||
content, [], Usage(input_tokens=4, output_tokens=2), 1.5,
|
||||
{"role": "assistant", "content": content},
|
||||
)
|
||||
|
||||
|
||||
def test_llm_reranker_retry_corrects_an_empty_semantic_response():
|
||||
chat = FakeRerankerChat()
|
||||
reranker = LLMReranker("semantic", chat)
|
||||
documents = [Chunk("a", "c", "first", 1, 1), Chunk("b", "c", "second", 2, 2)]
|
||||
ranked = reranker.rerank("second", documents, 1)
|
||||
assert [row[0].chunk_id for row in ranked] == ["b"]
|
||||
assert chat.calls == 2
|
||||
assert "previous response was invalid" in chat.messages[1][-1]["content"]
|
||||
assert reranker.last_usage.input_tokens == 8
|
||||
assert reranker.last_latency_ms == 3.0
|
||||
|
||||
|
||||
def record(**overrides):
|
||||
from experiment import RunRecord
|
||||
|
||||
data = dict(
|
||||
experiment="7-11", test_id="t", layer="layer1", system="rag", embedding="e1",
|
||||
reranker="none", main_model="m1", success=True, reward=0.8, steps=2,
|
||||
tool_calls=1, latency_ms=100, cost_usd=0.01, input_tokens=10, output_tokens=2,
|
||||
unpriced_tokens=0, retrieval_hit_at_5=1.0, retrieval_recall_at_5=0.5,
|
||||
retrieval_mrr=1.0, fixed_query_hit_at_5=1.0, fixed_query_recall_at_5=0.5,
|
||||
fixed_query_mrr=1.0,
|
||||
)
|
||||
data.update(overrides)
|
||||
return RunRecord(**data)
|
||||
|
||||
|
||||
def test_aggregation_and_interaction_report_conditional_reranker_value():
|
||||
rows = [
|
||||
record(test_id="a"),
|
||||
record(test_id="b", success=False, reward=0.3),
|
||||
record(test_id="a", reranker="bge", success=True, reward=1.0, retrieval_recall_at_5=1.0, fixed_query_recall_at_5=1.0, latency_ms=130),
|
||||
record(test_id="b", reranker="bge", success=True, reward=0.9, retrieval_recall_at_5=1.0, fixed_query_recall_at_5=1.0, latency_ms=130),
|
||||
]
|
||||
summary = aggregate(rows, ["reranker"])
|
||||
assert {row["reranker"] for row in summary} == {"none", "bge"}
|
||||
analysis = interaction_analysis(rows)
|
||||
assert analysis["analysis_scope"]["selection_conclusions_allowed"] is False
|
||||
assert analysis["analysis_scope"]["scope_status"] == "partial_descriptive_only"
|
||||
delta = analysis["reranker_value_by_embedding_and_main_model"][0]
|
||||
assert delta["success_rate_delta"] == 0.5
|
||||
assert delta["fixed_query_recall_at_5_delta"] == 0.5
|
||||
assert delta["latency_ms_delta"] == 30
|
||||
|
||||
|
||||
def test_native_currency_pricing_tracks_cached_and_uncached_without_fx():
|
||||
pricing = TokenPricing.from_dict({
|
||||
"currency": "CNY",
|
||||
"as_of_date": "2026-07-29",
|
||||
"source_url": "https://provider.example/pricing",
|
||||
"input_per_million": 4.0,
|
||||
"cached_input_per_million": 0.7,
|
||||
"output_per_million": 21.0,
|
||||
})
|
||||
usage = pricing.price(1_000_000, 1_000_000, cached_input_tokens=250_000)
|
||||
assert usage.cost_by_currency == {"CNY": 24.175}
|
||||
assert usage.cost_usd == 0
|
||||
assert usage.cached_input_tokens == 250_000
|
||||
assert usage.unpriced_tokens == 0
|
||||
|
||||
|
||||
def test_pricing_requires_dated_source_and_three_letter_currency():
|
||||
import pytest
|
||||
|
||||
base = {
|
||||
"currency": "USD",
|
||||
"as_of_date": "2026-07-29",
|
||||
"source_url": "https://provider.example/pricing",
|
||||
"input_per_million": 1.0,
|
||||
}
|
||||
for override in (
|
||||
{"currency": "dollars"},
|
||||
{"as_of_date": "today"},
|
||||
{"source_url": "provider.example/pricing"},
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
TokenPricing.from_dict(base | override)
|
||||
|
||||
|
||||
def exact_711_config(readiness=True):
|
||||
return {
|
||||
"experiment_7_11": {
|
||||
"embeddings": ["e1", "e2", "e3", "e4"],
|
||||
"rerankers": ["none", "r1", "r2"],
|
||||
"main_models": ["m1", "m2"],
|
||||
},
|
||||
"execution_readiness": {"all_required_backends_ready": readiness},
|
||||
}
|
||||
|
||||
|
||||
def exact_711_records():
|
||||
return [
|
||||
record(
|
||||
test_id=f"case-{case:02d}",
|
||||
embedding=embedding,
|
||||
reranker=reranker,
|
||||
main_model=model,
|
||||
cost_usd=0,
|
||||
cost_by_currency={"USD": 0.001},
|
||||
)
|
||||
for case in range(60)
|
||||
for embedding in ("e1", "e2", "e3", "e4")
|
||||
for reranker in ("none", "r1", "r2")
|
||||
for model in ("m1", "m2")
|
||||
]
|
||||
|
||||
|
||||
def assess_611(rows, readiness=True):
|
||||
return completion_assessment(
|
||||
"7-11", rows, exact_711_config(readiness), pricing_coverage(rows)
|
||||
)
|
||||
|
||||
|
||||
def test_exact_711_gate_requires_all_1440_real_priced_successes_and_readiness():
|
||||
rows = exact_711_records()
|
||||
complete = assess_611(rows)
|
||||
assert complete["evidence_complete"] is True
|
||||
assert complete["expected_full_trajectory_count"] == 1440
|
||||
assert interaction_analysis(rows, complete)["analysis_scope"]["selection_conclusions_allowed"] is True
|
||||
|
||||
assert assess_611(rows[:-1])["evidence_complete"] is False
|
||||
|
||||
failed = list(rows)
|
||||
failed[0] = record(**({
|
||||
**vars(rows[0]), "status": "error", "success": False, "error": "provider failed",
|
||||
}))
|
||||
assert assess_611(failed)["evidence_complete"] is False
|
||||
|
||||
unpriced = list(rows)
|
||||
unpriced[0] = record(**({**vars(rows[0]), "unpriced_tokens": 1}))
|
||||
assert assess_611(unpriced)["cost_accounting_complete"] is False
|
||||
|
||||
non_api = list(rows)
|
||||
non_api[0] = record(**({**vars(rows[0]), "evidence_mode": "mock"}))
|
||||
assert assess_611(non_api)["real_api_evidence_only"] is False
|
||||
assert assess_611(rows, readiness=False)["backend_readiness_complete"] is False
|
||||
|
||||
|
||||
def test_pricing_coverage_recovers_schema1_fixed_query_usd():
|
||||
row = record(
|
||||
cost_usd=0.01,
|
||||
fixed_query_retrieval_cost_usd=0.002,
|
||||
fixed_query_retrieval_cost_by_currency={},
|
||||
fixed_query_unpriced_tokens=3,
|
||||
)
|
||||
coverage = pricing_coverage([row])
|
||||
assert coverage["total_cost_by_currency"] == {"USD": 0.012}
|
||||
assert coverage["observed_token_count"] == 15
|
||||
assert coverage["legacy_fixed_query_unpriced_token_lower_bound"] == 3
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Offline checkpoint-validity tests for the resumable full runner."""
|
||||
|
||||
import json
|
||||
|
||||
from run_full import required_611_cells, valid_checkpoint
|
||||
|
||||
|
||||
def record(experiment="7-4", status="ok", system="advanced_json_cards"):
|
||||
row = {
|
||||
"experiment": experiment,
|
||||
"test_id": "case-1",
|
||||
"status": status,
|
||||
"error": None,
|
||||
"rubric_details": {
|
||||
name: {"score": 4}
|
||||
for name in ("precision", "recall", "reasoning", "proactivity")
|
||||
},
|
||||
"hallucination_detail": {"detected": False},
|
||||
"embedding": "e",
|
||||
"reranker": "none",
|
||||
"main_model": "m",
|
||||
"system": system,
|
||||
}
|
||||
if status == "error":
|
||||
row["error"] = "provider unavailable"
|
||||
row["rubric_details"] = {}
|
||||
row["hallucination_detail"] = None
|
||||
return row
|
||||
|
||||
|
||||
def write_checkpoint(path, experiment, records):
|
||||
path.write_text(json.dumps({
|
||||
"experiment": experiment,
|
||||
"run_scope": {"requested_test_ids": ["case-1"]},
|
||||
"records": records,
|
||||
}))
|
||||
|
||||
|
||||
def test_64_checkpoint_requires_successful_full_rubric_rows(tmp_path):
|
||||
path = tmp_path / "case.json"
|
||||
rows = [record(system=name) for name in ("advanced_json_cards", "rag", "hybrid")]
|
||||
write_checkpoint(path, "7-4", rows)
|
||||
expected = {("advanced_json_cards",), ("rag",), ("hybrid",)}
|
||||
assert valid_checkpoint(path, "case-1", "7-4", 3, expected_cells=expected)
|
||||
|
||||
rows[0]["rubric_details"] = {}
|
||||
write_checkpoint(path, "7-4", rows)
|
||||
assert not valid_checkpoint(path, "case-1", "7-4", 3, expected_cells=expected)
|
||||
|
||||
rows[0] = record(status="error")
|
||||
write_checkpoint(path, "7-4", rows)
|
||||
assert not valid_checkpoint(path, "case-1", "7-4", 3, expected_cells=expected)
|
||||
|
||||
rows = [record(system="rag") for _ in range(3)]
|
||||
write_checkpoint(path, "7-4", rows)
|
||||
assert not valid_checkpoint(path, "case-1", "7-4", 3, expected_cells=expected)
|
||||
|
||||
|
||||
def test_611_checkpoint_preserves_explicit_provider_errors(tmp_path):
|
||||
path = tmp_path / "case.json"
|
||||
rows = [record("7-11"), record("7-11", status="error")]
|
||||
rows[1]["embedding"] = "blocked"
|
||||
write_checkpoint(path, "7-11", rows)
|
||||
assert valid_checkpoint(path, "case-1", "7-11", 2, {("e", "none", "m")})
|
||||
|
||||
rows[1]["error"] = None
|
||||
write_checkpoint(path, "7-11", rows)
|
||||
assert not valid_checkpoint(path, "case-1", "7-11", 2, {("e", "none", "m")})
|
||||
|
||||
rows[1]["error"] = "transient"
|
||||
rows[1]["embedding"] = "e"
|
||||
write_checkpoint(path, "7-11", rows)
|
||||
assert not valid_checkpoint(path, "case-1", "7-11", 2, {("e", "none", "m")})
|
||||
|
||||
|
||||
def test_required_611_cells_excludes_only_preflight_failures():
|
||||
config = {
|
||||
"experiment_7_11": {
|
||||
"embeddings": ["e-good", "e-bad"],
|
||||
"rerankers": ["none", "r-bad"],
|
||||
"main_models": ["m-good", "m-bad"],
|
||||
}
|
||||
}
|
||||
readiness = {
|
||||
"probes": [
|
||||
{"component": "embedding", "name": "e-bad", "status": "error"},
|
||||
{"component": "reranker", "name": "r-bad", "status": "error"},
|
||||
{"component": "chat", "name": "m-bad", "status": "error"},
|
||||
]
|
||||
}
|
||||
assert required_611_cells(config, readiness) == {("e-good", "none", "m-good")}
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Honest verification of the merged Experiment 7-11 (README row 7-11) full matrix.
|
||||
|
||||
Checks, without trusting the runner's own summary:
|
||||
1. All 60 cases present, each with exactly 24 cells (4 embeddings x 3 rerankers x 2 main models).
|
||||
2. 1,440 total records; zero error trajectories; zero unpriced requests/tokens.
|
||||
3. Retrieval metrics (hit@5, recall@5, MRR) and task metrics (reward, success) are
|
||||
populated and finite for every record.
|
||||
4. Embedding-index cost accounting present for every record.
|
||||
5. Interaction analysis: mean reward grouped by (embedding, reranker, main_model).
|
||||
|
||||
Usage: python3 validation/verify_full_matrix_20260731.py [path-to-matrix.json]
|
||||
Exit 0 only if every hard check passes.
|
||||
"""
|
||||
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
|
||||
PATH = sys.argv[1] if len(sys.argv) > 1 else "results/full_7_11_60_case_matrix.json"
|
||||
|
||||
failures = []
|
||||
|
||||
|
||||
def check(cond, msg):
|
||||
if not cond:
|
||||
failures.append(msg)
|
||||
|
||||
|
||||
def main():
|
||||
with open(PATH) as f:
|
||||
data = json.load(f)
|
||||
|
||||
records = data.get("records", [])
|
||||
by_case = defaultdict(list)
|
||||
for r in records:
|
||||
by_case[r["test_id"]].append(r)
|
||||
|
||||
# 1. coverage
|
||||
check(len(by_case) == 60, f"expected 60 cases, got {len(by_case)}")
|
||||
for tid, recs in sorted(by_case.items()):
|
||||
check(len(recs) == 24, f"{tid}: expected 24 cells, got {len(recs)}")
|
||||
combos = {(r["embedding"], r["reranker"], r["main_model"]) for r in recs}
|
||||
check(len(combos) == 24, f"{tid}: duplicate/missing cell combos ({len(combos)} unique)")
|
||||
|
||||
# 2. totals and cleanliness
|
||||
check(len(records) == 1440, f"expected 1440 records, got {len(records)}")
|
||||
errors = [r for r in records if r.get("status") == "error" or r.get("error")]
|
||||
check(not errors, f"{len(errors)} error trajectories")
|
||||
unpriced_req = sum(r.get("unpriced_requests", 0) for r in records)
|
||||
unpriced_tok = sum(r.get("unpriced_tokens", 0) for r in records)
|
||||
check(unpriced_req == 0 and unpriced_tok == 0,
|
||||
f"unpriced usage: {unpriced_req} requests, {unpriced_tok} tokens")
|
||||
|
||||
# 3. metrics populated
|
||||
metric_fields = ["retrieval_hit_at_5", "retrieval_recall_at_5", "retrieval_mrr", "reward"]
|
||||
for field in metric_fields:
|
||||
bad = [r["test_id"] for r in records
|
||||
if not isinstance(r.get(field), (int, float)) or not math.isfinite(r[field])]
|
||||
check(not bad, f"metric {field} missing/non-finite in {len(bad)} records (e.g. {bad[:3]})")
|
||||
|
||||
# 4. embedding index cost accounting
|
||||
no_idx = [r["test_id"] for r in records if r.get("embedding_index_latency_ms") is None]
|
||||
check(not no_idx, f"embedding index accounting missing in {len(no_idx)} records")
|
||||
|
||||
# 5. interaction analysis (informational, always printed)
|
||||
groups = defaultdict(list)
|
||||
for r in records:
|
||||
groups[(r["embedding"], r["reranker"], r["main_model"])].append(r["reward"])
|
||||
print("mean reward by (embedding, reranker, main_model):")
|
||||
for combo in sorted(groups):
|
||||
vals = groups[combo]
|
||||
print(f" {combo}: {sum(vals)/len(vals):.4f} (n={len(vals)})")
|
||||
|
||||
total_cost = sum(r.get("cost_usd") or 0 for r in records)
|
||||
print(f"total main-model cost: ${total_cost:.2f}")
|
||||
print(f"records: {len(records)}, cases: {len(by_case)}")
|
||||
|
||||
if failures:
|
||||
print("\nFAILURES:")
|
||||
for msg in failures:
|
||||
print(f" - {msg}")
|
||||
sys.exit(1)
|
||||
print("\nALL CHECKS PASSED")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user