feat(fb-39): eval foundation — precision_at_k_chunk metric #136
@@ -184,6 +184,18 @@ pub fn render_report_md(report: &CompareReport) -> String {
|
||||
),
|
||||
);
|
||||
}
|
||||
for k in crate::metrics::TOP_K_VARIANTS {
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"| precision@{k}_chunk | {} | {} | {} |",
|
||||
fmt(a.precision_at_k_chunk.get(k).copied().unwrap_or(f32::NAN)),
|
||||
fmt(b.precision_at_k_chunk.get(k).copied().unwrap_or(f32::NAN)),
|
||||
fmt_delta(
|
||||
a.precision_at_k_chunk.get(k).copied().unwrap_or(f32::NAN),
|
||||
b.precision_at_k_chunk.get(k).copied().unwrap_or(f32::NAN),
|
||||
),
|
||||
);
|
||||
}
|
||||
let _ = writeln!(
|
||||
out,
|
||||
"| citation_coverage | {} | {} | {} |",
|
||||
@@ -419,6 +431,7 @@ fn build_deltas(
|
||||
}
|
||||
let mut hit = serde_json::Map::new();
|
||||
let mut recall = serde_json::Map::new();
|
||||
let mut precision = serde_json::Map::new();
|
||||
for k in crate::metrics::TOP_K_VARIANTS {
|
||||
hit.insert(
|
||||
k.to_string(),
|
||||
@@ -434,11 +447,19 @@ fn build_deltas(
|
||||
b.recall_at_k_doc.get(k).copied().unwrap_or(f32::NAN),
|
||||
),
|
||||
);
|
||||
precision.insert(
|
||||
k.to_string(),
|
||||
d(
|
||||
a.precision_at_k_chunk.get(k).copied().unwrap_or(f32::NAN),
|
||||
b.precision_at_k_chunk.get(k).copied().unwrap_or(f32::NAN),
|
||||
),
|
||||
);
|
||||
}
|
||||
serde_json::json!({
|
||||
"hit_at_k": hit,
|
||||
"mrr": d(a.mrr, b.mrr),
|
||||
"recall_at_k_doc": recall,
|
||||
"precision_at_k_chunk": precision,
|
||||
"citation_coverage": d(a.citation_coverage, b.citation_coverage),
|
||||
"groundedness": d(a.groundedness, b.groundedness),
|
||||
"empty_result_rate": d(a.empty_result_rate, b.empty_result_rate),
|
||||
@@ -484,6 +505,7 @@ mod tests {
|
||||
hit_at_k: Default::default(),
|
||||
mrr: 0.5,
|
||||
recall_at_k_doc: Default::default(),
|
||||
precision_at_k_chunk: Default::default(),
|
||||
citation_coverage: f32::NAN,
|
||||
groundedness: 0.0,
|
||||
empty_result_rate: 0.0,
|
||||
|
||||
@@ -58,6 +58,14 @@ pub struct AggregateMetrics {
|
||||
pub hit_at_k: BTreeMap<u32, f32>,
|
||||
pub mrr: f32,
|
||||
pub recall_at_k_doc: BTreeMap<u32, f32>,
|
||||
/// p9-fb-39: chunk-level precision at k. Binary relevance via
|
||||
/// `expected_chunk_ids` (a hit is "relevant" if its chunk_id is
|
||||
/// in the golden's `expected_chunk_ids`). Denominator is k (fixed)
|
||||
/// — `hits.len() < k` still divides by k, treating shortfall as
|
||||
/// precision loss (mirrors `hit_at_k`). Queries with empty
|
||||
/// `expected_chunk_ids` are skipped (mirrors `hit_at_k_chunk`).
|
||||
#[serde(default)]
|
||||
pub precision_at_k_chunk: BTreeMap<u32, f32>,
|
||||
#[serde(
|
||||
serialize_with = "serialize_f32_nan_as_null",
|
||||
deserialize_with = "deserialize_f32_or_nan"
|
||||
@@ -187,6 +195,8 @@ pub(crate) fn aggregate_from_rows(
|
||||
TOP_K_VARIANTS.iter().map(|k| (*k, (0_u32, 0_u32))).collect();
|
||||
let mut recall_at_k_doc: BTreeMap<u32, (f64, u32)> =
|
||||
TOP_K_VARIANTS.iter().map(|k| (*k, (0.0_f64, 0_u32))).collect();
|
||||
let mut precision_at_k_chunk: BTreeMap<u32, (f64, u32)> =
|
||||
TOP_K_VARIANTS.iter().map(|k| (*k, (0.0_f64, 0_u32))).collect();
|
||||
|
||||
let mut mrr_sum: f64 = 0.0;
|
||||
let mut mrr_denom: u32 = 0;
|
||||
@@ -243,6 +253,18 @@ pub(crate) fn aggregate_from_rows(
|
||||
{
|
||||
mrr_sum += 1.0 / f64::from(rank);
|
||||
}
|
||||
// p9-fb-39: precision@k_chunk — count of top-k hits whose
|
||||
// chunk_id is in `expected`, divided by k (fixed denominator).
|
||||
for k in TOP_K_VARIANTS {
|
||||
let hits_in_topk_relevant = qr
|
||||
.hits_top_k
|
||||
.iter()
|
||||
.filter(|h| h.rank <= *k && expected.contains(&h.chunk_id))
|
||||
.count();
|
||||
let entry = precision_at_k_chunk.get_mut(k).expect("init");
|
||||
entry.0 += hits_in_topk_relevant as f64 / f64::from(*k);
|
||||
entry.1 += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// recall@k_doc (doc-level, requires non-empty expected_doc_ids
|
||||
@@ -333,6 +355,7 @@ pub(crate) fn aggregate_from_rows(
|
||||
mrr_sum / f64::from(mrr_denom)
|
||||
}),
|
||||
recall_at_k_doc: round_recall_map(&recall_at_k_doc),
|
||||
precision_at_k_chunk: round_recall_map(&precision_at_k_chunk),
|
||||
citation_coverage: ratio_or_nan(citation_num, citation_denom),
|
||||
groundedness: ratio_or_zero(groundedness_num, groundedness_denom),
|
||||
empty_result_rate: ratio_or_zero(empty_result_count, total_queries),
|
||||
@@ -674,4 +697,114 @@ mod tests {
|
||||
assert_eq!(agg.failed_queries, 1);
|
||||
assert_eq!(agg.total_queries, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_at_k_chunk_field_default_empty_on_old_json() {
|
||||
// Old eval_runs.metrics_json predates fb-39 — no precision_at_k_chunk field.
|
||||
// serde(default) yields empty BTreeMap.
|
||||
let old = serde_json::json!({
|
||||
"hit_at_k": {"1": 0.5, "3": 0.5, "5": 0.5, "10": 0.5},
|
||||
"mrr": 0.5,
|
||||
"recall_at_k_doc": {"1": 0.0, "3": 0.0, "5": 0.0, "10": 0.0},
|
||||
"citation_coverage": null,
|
||||
"groundedness": 0.0,
|
||||
"empty_result_rate": 0.0,
|
||||
"refusal_correctness": null,
|
||||
"total_queries": 1,
|
||||
"failed_queries": 0
|
||||
});
|
||||
let parsed: AggregateMetrics =
|
||||
serde_json::from_value(old).expect("backwards-compat deserialize");
|
||||
assert!(parsed.precision_at_k_chunk.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_at_k_chunk_exact_match() {
|
||||
// expected = [c1, c2, c3]. Top-5 hits: [c1@1, c2@2, c3@3, x@4, y@5].
|
||||
// P@5 = 3/5 = 0.6. P@10 = 3/10 = 0.3.
|
||||
let queries = vec![gq("q1", &["c1", "c2", "c3"], &["d1"])];
|
||||
let rows = vec![record(
|
||||
"q1",
|
||||
vec![
|
||||
hit(1, "c1", "d1"),
|
||||
hit(2, "c2", "d1"),
|
||||
hit(3, "c3", "d1"),
|
||||
hit(4, "x", "d1"),
|
||||
hit(5, "y", "d1"),
|
||||
],
|
||||
None,
|
||||
None,
|
||||
)];
|
||||
let agg = aggregate_from_rows(&queries, &rows).unwrap();
|
||||
assert_eq!(agg.precision_at_k_chunk[&5], 0.6);
|
||||
assert_eq!(agg.precision_at_k_chunk[&10], 0.3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_at_k_chunk_partial_topk_divides_by_k() {
|
||||
// expected = [c1, c2]. Hits: only [c1@1, c2@2, x@3] (3 results).
|
||||
// P@5 = 2/5 = 0.4 (denominator is k, not hits.len()).
|
||||
let queries = vec![gq("q1", &["c1", "c2"], &["d1"])];
|
||||
let rows = vec![record(
|
||||
"q1",
|
||||
vec![hit(1, "c1", "d1"), hit(2, "c2", "d1"), hit(3, "x", "d1")],
|
||||
None,
|
||||
None,
|
||||
)];
|
||||
let agg = aggregate_from_rows(&queries, &rows).unwrap();
|
||||
assert_eq!(agg.precision_at_k_chunk[&5], 0.4);
|
||||
assert_eq!(agg.precision_at_k_chunk[&10], 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_at_k_chunk_zero_relevant_in_topk() {
|
||||
// expected = [c1]. Hits: [x@1, y@2, z@3] (none relevant).
|
||||
// P@5 = 0/5 = 0.0.
|
||||
let queries = vec![gq("q1", &["c1"], &["d1"])];
|
||||
let rows = vec![record(
|
||||
"q1",
|
||||
vec![hit(1, "x", "d1"), hit(2, "y", "d1"), hit(3, "z", "d1")],
|
||||
None,
|
||||
None,
|
||||
)];
|
||||
let agg = aggregate_from_rows(&queries, &rows).unwrap();
|
||||
assert_eq!(agg.precision_at_k_chunk[&5], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_at_k_chunk_empty_expected_skipped() {
|
||||
// expected_chunk_ids = []. Skipped → final BTreeMap entry value = 0.0
|
||||
// (zero-denom path in round_recall_map). Mirrors recall_at_k_doc behavior.
|
||||
let queries = vec![gq("q1", &[], &["d1"])];
|
||||
let rows = vec![record("q1", vec![hit(1, "c1", "d1")], None, None)];
|
||||
let agg = aggregate_from_rows(&queries, &rows).unwrap();
|
||||
assert_eq!(agg.precision_at_k_chunk[&5], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_at_k_chunk_two_queries_averaged() {
|
||||
// q1: expected=[c1], hits=[c1@1, x@2, y@3] → P@5 = 1/5 = 0.2
|
||||
// q2: expected=[c1, c2], hits=[c1@1, c2@2] → P@5 = 2/5 = 0.4
|
||||
// Avg P@5 = 0.3.
|
||||
let queries = vec![
|
||||
gq("q1", &["c1"], &["d1"]),
|
||||
gq("q2", &["c1", "c2"], &["d2"]),
|
||||
];
|
||||
let rows = vec![
|
||||
record(
|
||||
"q1",
|
||||
vec![hit(1, "c1", "d1"), hit(2, "x", "d1"), hit(3, "y", "d1")],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
record(
|
||||
"q2",
|
||||
vec![hit(1, "c1", "d2"), hit(2, "c2", "d2")],
|
||||
None,
|
||||
None,
|
||||
),
|
||||
];
|
||||
let agg = aggregate_from_rows(&queries, &rows).unwrap();
|
||||
assert_eq!(agg.precision_at_k_chunk[&5], 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
"5": 0.666700005531311
|
||||
},
|
||||
"mrr": 0.41670000553131104,
|
||||
"precision_at_k_chunk": {
|
||||
"1": 0.33329999446868896,
|
||||
"10": 0.06669999659061432,
|
||||
"3": 0.11110000312328339,
|
||||
"5": 0.13330000638961792
|
||||
},
|
||||
"recall_at_k_doc": {
|
||||
"1": 0.33329999446868896,
|
||||
"10": 0.666700005531311,
|
||||
@@ -32,6 +38,12 @@
|
||||
"5": 1.0
|
||||
},
|
||||
"mrr": 0.833299994468689,
|
||||
"precision_at_k_chunk": {
|
||||
"1": 0.666700005531311,
|
||||
"10": 0.10000000149011612,
|
||||
"3": 0.33329999446868896,
|
||||
"5": 0.20000000298023224
|
||||
},
|
||||
"recall_at_k_doc": {
|
||||
"1": 0.666700005531311,
|
||||
"10": 1.0,
|
||||
@@ -53,6 +65,12 @@
|
||||
"5": 0.33329999446868896
|
||||
},
|
||||
"mrr": 0.41659998893737793,
|
||||
"precision_at_k_chunk": {
|
||||
"1": 0.33340001106262207,
|
||||
"10": 0.0333000048995018,
|
||||
"3": 0.22219999134540558,
|
||||
"5": 0.06669999659061432
|
||||
},
|
||||
"recall_at_k_doc": {
|
||||
"1": 0.33340001106262207,
|
||||
"10": 0.33329999446868896,
|
||||
|
||||
@@ -203,6 +203,7 @@ fn store_aggregate_rejects_missing_run() {
|
||||
hit_at_k: Default::default(),
|
||||
mrr: 0.0,
|
||||
recall_at_k_doc: Default::default(),
|
||||
precision_at_k_chunk: Default::default(),
|
||||
citation_coverage: f32::NAN,
|
||||
groundedness: 0.0,
|
||||
empty_result_rate: 0.0,
|
||||
|
||||
418
docs/superpowers/plans/2026-05-10-p9-fb-39-eval-foundation.md
Normal file
418
docs/superpowers/plans/2026-05-10-p9-fb-39-eval-foundation.md
Normal file
@@ -0,0 +1,418 @@
|
||||
# fb-39 Eval Foundation (P@k Metric) Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add chunk-level `precision_at_k_chunk` metric (P@5, P@10) to kebab-eval `AggregateMetrics`, plus golden-set ground-truth documentation strengthening — so a future fb-39b can measure whether a lever (chunk policy / RRF / cross-encoder / embedding upgrade) actually moves the rank-5+ noise needle.
|
||||
|
||||
**Architecture:** Single new field on `AggregateMetrics`, computed inside the existing `compute_aggregate_with_config` loop using the same accumulator pattern as `recall_at_k_doc` (sum-of-per-query-ratios / denominator), serialized via the existing `round_recall_map` helper. Denominator is k (fixed), matching the `hit_at_k` convention. Skip queries with empty `expected_chunk_ids`. Golden set schema unchanged — `expected_chunk_ids` is the ground truth (curator fills per-workspace).
|
||||
|
||||
**Tech Stack:** Rust 2024, serde, serde_yaml. No new deps.
|
||||
|
||||
**Spec:** `docs/superpowers/specs/2026-05-10-p9-fb-39-eval-foundation-design.md`
|
||||
|
||||
---
|
||||
|
||||
## File map
|
||||
|
||||
**Modify:**
|
||||
- `crates/kebab-eval/src/metrics.rs` — add `precision_at_k_chunk` field on `AggregateMetrics`, init/accumulate/finalize inside `compute_aggregate_with_config`, plus unit tests.
|
||||
- `fixtures/golden_queries.yaml` — strengthen header comment about `expected_chunk_ids` being P@k ground truth.
|
||||
- `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` — add `precision_at_k_chunk` to §11 eval metric table.
|
||||
- `tasks/p9/p9-fb-39-retrieval-precision-tuning.md` — flip status, link design + plan, "lever 적용 deferred to fb-39b" banner.
|
||||
- `tasks/INDEX.md` — flip fb-39 row to ✅ (eval foundation only).
|
||||
|
||||
**Create:** none.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add precision_at_k_chunk field + serde backwards-compat
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-eval/src/metrics.rs`
|
||||
|
||||
- [ ] **Step 1: Append failing test to `mod tests`**
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn precision_at_k_chunk_field_default_empty_on_old_json() {
|
||||
// Old eval_runs.metrics_json predates fb-39 — no precision_at_k_chunk field.
|
||||
// serde(default) should yield empty BTreeMap.
|
||||
let old = serde_json::json!({
|
||||
"hit_at_k": {"1": 0.5, "3": 0.5, "5": 0.5, "10": 0.5},
|
||||
"mrr": 0.5,
|
||||
"recall_at_k_doc": {"1": 0.0, "3": 0.0, "5": 0.0, "10": 0.0},
|
||||
"citation_coverage": null,
|
||||
"groundedness": 0.0,
|
||||
"empty_result_rate": 0.0,
|
||||
"refusal_correctness": null,
|
||||
"total_queries": 1,
|
||||
"failed_queries": 0
|
||||
});
|
||||
let parsed: AggregateMetrics = serde_json::from_value(old).expect("backwards-compat deserialize");
|
||||
assert!(parsed.precision_at_k_chunk.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_at_k_chunk_field_serializes_when_populated() {
|
||||
let mut p = BTreeMap::new();
|
||||
p.insert(5, 0.6_f32);
|
||||
p.insert(10, 0.3_f32);
|
||||
let agg = AggregateMetrics {
|
||||
hit_at_k: BTreeMap::new(),
|
||||
mrr: 0.0,
|
||||
recall_at_k_doc: BTreeMap::new(),
|
||||
precision_at_k_chunk: p,
|
||||
citation_coverage: 0.0,
|
||||
groundedness: 0.0,
|
||||
empty_result_rate: 0.0,
|
||||
refusal_correctness: 0.0,
|
||||
total_queries: 0,
|
||||
failed_queries: 0,
|
||||
};
|
||||
let v = serde_json::to_value(&agg).unwrap();
|
||||
assert_eq!(v["precision_at_k_chunk"]["5"], 0.6);
|
||||
assert_eq!(v["precision_at_k_chunk"]["10"], 0.3);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run tests — expect compile errors (field undefined)**
|
||||
|
||||
```bash
|
||||
cargo test -p kebab-eval --lib precision_at_k_chunk
|
||||
```
|
||||
Expected: errors — `precision_at_k_chunk` field missing on `AggregateMetrics`.
|
||||
|
||||
- [ ] **Step 3: Add field to `AggregateMetrics`**
|
||||
|
||||
In `crates/kebab-eval/src/metrics.rs`, find `pub struct AggregateMetrics { ... }` (~line 57). Add field after `recall_at_k_doc`:
|
||||
|
||||
```rust
|
||||
/// p9-fb-39: chunk-level precision at k. Binary relevance via
|
||||
/// `expected_chunk_ids` (a hit is "relevant" if its chunk_id is
|
||||
/// in the golden's `expected_chunk_ids`). Denominator is k (fixed)
|
||||
/// — `hits.len() < k` still divides by k, treating shortfall as
|
||||
/// precision loss (mirrors `hit_at_k`). Queries with empty
|
||||
/// `expected_chunk_ids` are skipped (mirrors `hit_at_k_chunk`).
|
||||
#[serde(default)]
|
||||
pub precision_at_k_chunk: BTreeMap<u32, f32>,
|
||||
```
|
||||
|
||||
The other tests in the file (e.g. `hit_at_k_handles_ranks_1_4_miss`, `recall_at_k_doc_partial`) construct `AggregateMetrics` via the public `compute_aggregate_with_config` path, not via struct literal, so the new `#[serde(default)]` field does NOT break them. Only direct struct-literal constructions need updates — search the file to confirm:
|
||||
|
||||
```bash
|
||||
grep -n "AggregateMetrics {" crates/kebab-eval/src/metrics.rs
|
||||
```
|
||||
|
||||
For each direct struct-literal site, add `precision_at_k_chunk: BTreeMap::new(),` to the literal.
|
||||
|
||||
- [ ] **Step 4: Run tests — expect both new tests pass**
|
||||
|
||||
```bash
|
||||
cargo test -p kebab-eval --lib precision_at_k_chunk
|
||||
```
|
||||
Expected: both pass.
|
||||
|
||||
- [ ] **Step 5: Run clippy**
|
||||
|
||||
```bash
|
||||
cargo clippy -p kebab-eval --all-targets -- -D warnings
|
||||
```
|
||||
Expected: clean.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/kebab-eval/src/metrics.rs
|
||||
git commit -m "feat(eval): AggregateMetrics.precision_at_k_chunk field (fb-39)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Compute precision_at_k_chunk in aggregate loop
|
||||
|
||||
**Files:**
|
||||
- Modify: `crates/kebab-eval/src/metrics.rs`
|
||||
|
||||
- [ ] **Step 1: Append failing tests to `mod tests`**
|
||||
|
||||
(Use the existing `make_query_result` / fixture helpers — read the top of the test module for available helpers, e.g. `mk_qr_with_chunks(query_id, chunk_ids_with_ranks)`.)
|
||||
|
||||
```rust
|
||||
#[test]
|
||||
fn precision_at_k_chunk_exact_match() {
|
||||
// 1 query, expected = [c1, c2, c3]. Top-5 hits: [c1@1, c2@2, c3@3, x@4, y@5].
|
||||
// P@5 = 3/5 = 0.6. P@10 = 3/10 = 0.3.
|
||||
let queries = vec![mk_golden(
|
||||
"g1",
|
||||
&[], // expected_doc_ids
|
||||
&["c1", "c2", "c3"], // expected_chunk_ids
|
||||
&[], // must_contain
|
||||
&[], // forbidden
|
||||
None, // expected_refusal
|
||||
)];
|
||||
let rows = vec![mk_query_row(
|
||||
"g1",
|
||||
&[("c1", 1), ("c2", 2), ("c3", 3), ("x", 4), ("y", 5)],
|
||||
)];
|
||||
let agg = compute_from_inputs(&queries, &rows);
|
||||
assert_eq!(agg.precision_at_k_chunk[&5], 0.6);
|
||||
assert_eq!(agg.precision_at_k_chunk[&10], 0.3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_at_k_chunk_partial_topk_divides_by_k() {
|
||||
// 1 query, expected = [c1, c2]. Top hits: only [c1@1, c2@2] (3 results total).
|
||||
// P@5 = 2/5 = 0.4 (denominator k, not hits.len).
|
||||
let queries = vec![mk_golden("g1", &[], &["c1", "c2"], &[], &[], None)];
|
||||
let rows = vec![mk_query_row("g1", &[("c1", 1), ("c2", 2), ("x", 3)])];
|
||||
let agg = compute_from_inputs(&queries, &rows);
|
||||
assert_eq!(agg.precision_at_k_chunk[&5], 0.4);
|
||||
assert_eq!(agg.precision_at_k_chunk[&10], 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_at_k_chunk_zero_relevant_in_topk() {
|
||||
// 1 query, expected = [c1]. Top hits all unrelated.
|
||||
// P@5 = 0/5 = 0.0.
|
||||
let queries = vec![mk_golden("g1", &[], &["c1"], &[], &[], None)];
|
||||
let rows = vec![mk_query_row("g1", &[("x", 1), ("y", 2), ("z", 3)])];
|
||||
let agg = compute_from_inputs(&queries, &rows);
|
||||
assert_eq!(agg.precision_at_k_chunk[&5], 0.0);
|
||||
assert_eq!(agg.precision_at_k_chunk[&10], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_at_k_chunk_empty_expected_skipped() {
|
||||
// 1 query, expected_chunk_ids = []. Should be skipped — denom 0 → entry value 0.0
|
||||
// (matches `recall_at_k_doc` behavior in `round_recall_map` for zero-denom).
|
||||
let queries = vec![mk_golden("g1", &[], &[], &[], &[], None)];
|
||||
let rows = vec![mk_query_row("g1", &[("c1", 1)])];
|
||||
let agg = compute_from_inputs(&queries, &rows);
|
||||
// Mirrors recall_at_k_doc: zero-denom → 0.0 in map (not absent).
|
||||
assert_eq!(agg.precision_at_k_chunk[&5], 0.0);
|
||||
assert_eq!(agg.precision_at_k_chunk[&10], 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn precision_at_k_chunk_two_queries_averaged() {
|
||||
// q1: expected=[c1], hits=[c1@1, x@2, y@3] → P@5 = 1/5 = 0.2
|
||||
// q2: expected=[c1, c2], hits=[c1@1, c2@2] → P@5 = 2/5 = 0.4
|
||||
// Avg P@5 = (0.2 + 0.4) / 2 = 0.3.
|
||||
let queries = vec![
|
||||
mk_golden("g1", &[], &["c1"], &[], &[], None),
|
||||
mk_golden("g2", &[], &["c1", "c2"], &[], &[], None),
|
||||
];
|
||||
let rows = vec![
|
||||
mk_query_row("g1", &[("c1", 1), ("x", 2), ("y", 3)]),
|
||||
mk_query_row("g2", &[("c1", 1), ("c2", 2)]),
|
||||
];
|
||||
let agg = compute_from_inputs(&queries, &rows);
|
||||
assert_eq!(agg.precision_at_k_chunk[&5], 0.3);
|
||||
}
|
||||
```
|
||||
|
||||
The `mk_golden` / `mk_query_row` / `compute_from_inputs` helpers are existing test helpers in this file. Read the top of `mod tests` (~line 380-510) to confirm the actual helper names and signatures. If your helpers have different shapes (e.g. `mk_qr_with_chunks(id, &[(chunk, rank)])`), adapt the test calls accordingly.
|
||||
|
||||
If those helpers don't exist, look for the pattern in the existing `hit_at_k_handles_ranks_1_4_miss` test (~line 513) and mirror it.
|
||||
|
||||
- [ ] **Step 2: Run tests — expect failures**
|
||||
|
||||
```bash
|
||||
cargo test -p kebab-eval --lib precision_at_k_chunk
|
||||
```
|
||||
Expected: 5 failures — `precision_at_k_chunk` map empty (only `#[serde(default)]` populates it from JSON; the compute path doesn't yet).
|
||||
|
||||
- [ ] **Step 3: Implement aggregation in `compute_aggregate_with_config`**
|
||||
|
||||
In `crates/kebab-eval/src/metrics.rs`, find `compute_aggregate_with_config` body. After the `recall_at_k_doc` accumulator init (~line 188-189), add:
|
||||
|
||||
```rust
|
||||
let mut precision_at_k_chunk: BTreeMap<u32, (f64, u32)> =
|
||||
TOP_K_VARIANTS.iter().map(|k| (*k, (0.0_f64, 0_u32))).collect();
|
||||
```
|
||||
|
||||
Inside the loop, after the existing `hit@k + MRR` block (~line 222-247) which already gates on `!gq.expected_chunk_ids.is_empty()`, add a sibling `for k in TOP_K_VARIANTS { ... }` that updates `precision_at_k_chunk`. Place it INSIDE the same `if !gq.expected_chunk_ids.is_empty() { ... }` block so the skip-empty policy is shared:
|
||||
|
||||
```rust
|
||||
// hit@k + MRR (chunk-level, requires non-empty expected_chunk_ids)
|
||||
if !gq.expected_chunk_ids.is_empty() {
|
||||
let expected: HashSet<&ChunkId> = gq.expected_chunk_ids.iter().collect();
|
||||
// ... existing hit@k + MRR computation ...
|
||||
|
||||
// p9-fb-39: precision@k_chunk — count of top-k hits whose
|
||||
// chunk_id is in `expected`, divided by k (fixed denominator).
|
||||
for k in TOP_K_VARIANTS {
|
||||
let hits_in_topk_relevant = qr
|
||||
.hits_top_k
|
||||
.iter()
|
||||
.filter(|h| h.rank <= *k && expected.contains(&h.chunk_id))
|
||||
.count();
|
||||
let entry = precision_at_k_chunk.get_mut(k).expect("init");
|
||||
entry.0 += hits_in_topk_relevant as f64 / f64::from(*k);
|
||||
entry.1 += 1;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then at the final `Ok(AggregateMetrics { ... })` return (~line 325-345), add:
|
||||
|
||||
```rust
|
||||
precision_at_k_chunk: round_recall_map(&precision_at_k_chunk),
|
||||
```
|
||||
|
||||
(`round_recall_map` is the existing helper at line ~366; it accepts `BTreeMap<u32, (f64, u32)>` and divides sum by denom, returning `BTreeMap<u32, f32>`. Same shape used by `recall_at_k_doc`.)
|
||||
|
||||
- [ ] **Step 4: Run tests — expect all 5 pass**
|
||||
|
||||
```bash
|
||||
cargo test -p kebab-eval --lib precision_at_k_chunk
|
||||
```
|
||||
Expected: 5 passes.
|
||||
|
||||
- [ ] **Step 5: Run full kebab-eval suite**
|
||||
|
||||
```bash
|
||||
cargo test -p kebab-eval
|
||||
cargo clippy -p kebab-eval --all-targets -- -D warnings
|
||||
```
|
||||
Expected: no regressions; clippy clean.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add crates/kebab-eval/src/metrics.rs
|
||||
git commit -m "feat(eval): compute precision_at_k_chunk in aggregate loop (fb-39)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Strengthen golden YAML header documentation
|
||||
|
||||
**Files:**
|
||||
- Modify: `fixtures/golden_queries.yaml`
|
||||
|
||||
- [ ] **Step 1: Read existing header**
|
||||
|
||||
```bash
|
||||
head -20 fixtures/golden_queries.yaml
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Replace header comment**
|
||||
|
||||
Find the existing header (the comment block above the first `- id: g001` entry). Replace with:
|
||||
|
||||
```yaml
|
||||
# Golden query suite for `kebab eval run` (P5-1 / P5-2 / fb-39).
|
||||
#
|
||||
# Top-level: list of queries. Required fields: `id`, `query`. All
|
||||
# others are optional and default to empty / null.
|
||||
#
|
||||
# Curators: `expected_doc_ids` and `expected_chunk_ids` MUST refer to
|
||||
# real rows in the active workspace's SQLite store at run time. Stale
|
||||
# references make the runner bail at start. The shipped template
|
||||
# leaves them empty so the file is loadable on any fresh workspace —
|
||||
# fill them in after a `kebab ingest` to enable the metrics that
|
||||
# require ground truth (P5-2 + fb-39):
|
||||
#
|
||||
# - `expected_chunk_ids` → hit_at_k, MRR, precision_at_k_chunk (fb-39)
|
||||
# - `expected_doc_ids` → recall_at_k_doc
|
||||
#
|
||||
# `precision_at_k_chunk` (fb-39): of the top-k retrieved hits, what
|
||||
# fraction's `chunk_id` is in `expected_chunk_ids`. Denominator is k
|
||||
# (fixed) — `top-k` shortfall is treated as precision loss. Queries
|
||||
# with empty `expected_chunk_ids` are skipped from this metric.
|
||||
#
|
||||
# `must_contain` / `forbidden` drive the rule-based groundedness
|
||||
# metric (P5-2).
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Verify YAML still parses**
|
||||
|
||||
```bash
|
||||
cargo test -p kebab-eval --test golden_loader 2>/dev/null || cargo test -p kebab-eval load_golden
|
||||
```
|
||||
|
||||
If a loader test exists, it should still pass. If not, run a quick parse check:
|
||||
|
||||
```bash
|
||||
cargo run --bin kebab -- eval --help 2>/dev/null || true
|
||||
```
|
||||
|
||||
(The shipped `golden_queries.yaml` is just a fixture — the workspace test loader will read it during integration tests and fail loudly if YAML is malformed.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add fixtures/golden_queries.yaml
|
||||
git commit -m "docs(eval): document expected_chunk_ids as P@k ground truth (fb-39)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Update design doc + spec status flip + INDEX
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md`
|
||||
- Modify: `tasks/p9/p9-fb-39-retrieval-precision-tuning.md`
|
||||
- Modify: `tasks/INDEX.md`
|
||||
|
||||
- [ ] **Step 1: Update design §11 eval metric list**
|
||||
|
||||
```bash
|
||||
grep -n "^## §11\|^## 11\|hit_at_k\|recall_at_k_doc\|precision" docs/superpowers/specs/2026-04-27-kebab-final-form-design.md | head -10
|
||||
```
|
||||
|
||||
Find the §11 eval section (or wherever metrics are listed). Add a `precision_at_k_chunk` line next to `hit_at_k` / `recall_at_k_doc`:
|
||||
|
||||
```markdown
|
||||
- `precision_at_k_chunk` (fb-39): top-k 안 chunk_id 가 `expected_chunk_ids` 에 포함된 비율. 분모 = k (fixed). `expected_chunk_ids` 빈 query 는 skip.
|
||||
```
|
||||
|
||||
If the design doc doesn't currently list metrics inline, add a short subsection or bullet under §11 introducing it.
|
||||
|
||||
- [ ] **Step 2: Flip task spec status**
|
||||
|
||||
```bash
|
||||
sed -i.bak 's/^status: open$/status: completed/' tasks/p9/p9-fb-39-retrieval-precision-tuning.md
|
||||
rm tasks/p9/p9-fb-39-retrieval-precision-tuning.md.bak
|
||||
```
|
||||
|
||||
Replace the existing `> ⏳ **백로그 only — 미구현.**` skeleton banner with:
|
||||
|
||||
```markdown
|
||||
> ✅ **Eval foundation 부분 구현 완료.** P@k metric (P@5, P@10) 추가. 본 spec 의 lever 적용 (chunk policy / RRF / cross-encoder / embedding 업그레이드) 은 별도 task 로 분리 (fb-39b 이후).
|
||||
>
|
||||
> - Design: [`docs/superpowers/specs/2026-05-10-p9-fb-39-eval-foundation-design.md`](../../docs/superpowers/specs/2026-05-10-p9-fb-39-eval-foundation-design.md)
|
||||
> - Plan: [`docs/superpowers/plans/2026-05-10-p9-fb-39-eval-foundation.md`](../../docs/superpowers/plans/2026-05-10-p9-fb-39-eval-foundation.md)
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Flip INDEX row**
|
||||
|
||||
In `tasks/INDEX.md`, find the fb-39 row. Replace its status with `✅ 머지 (2026-05-10) — eval foundation only, lever 적용 deferred` (mirror the fb-42 row format from the previous PR for consistency).
|
||||
|
||||
- [ ] **Step 4: Workspace test + clippy gate**
|
||||
|
||||
```bash
|
||||
cargo test --workspace --no-fail-fast -j 1 2>&1 | tail -10
|
||||
cargo clippy --workspace --all-targets -- -D warnings 2>&1 | tail -5
|
||||
```
|
||||
|
||||
`-j 1` REQUIRED.
|
||||
|
||||
Expected: all green.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-04-27-kebab-final-form-design.md tasks/p9/p9-fb-39-retrieval-precision-tuning.md tasks/INDEX.md
|
||||
git commit -m "docs(fb-39): design §11 + spec status + INDEX (eval foundation)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Final verification checklist
|
||||
|
||||
- [ ] `cargo test --workspace --no-fail-fast -j 1` green
|
||||
- [ ] `cargo clippy --workspace --all-targets -- -D warnings` clean
|
||||
- [ ] `kebab eval run` (against any workspace with non-empty `expected_chunk_ids` in golden) emits `precision_at_k_chunk: {5: ..., 10: ...}` in the run's `metrics_json`
|
||||
- [ ] design §11 + INDEX + task spec status flipped
|
||||
@@ -1510,6 +1510,26 @@ agent 가 분기). HTTP-SSE transport 는 fb-29 deferral 따라 P+. classify
|
||||
모듈은 `kebab-app::error_wire` 에 single source — kebab-cli + kebab-mcp
|
||||
공유.
|
||||
|
||||
### 10.3 Eval metrics (fb-39)
|
||||
|
||||
#### Retrieval metrics (ground-truth curated)
|
||||
|
||||
`kebab eval run` 이 golden query suite (`fixtures/golden_queries.yaml`) 대해 메트릭 계산. Curator 가 `expected_chunk_ids` 및 `expected_doc_ids` 설정 시에만 측정 가능 (shipped template 은 empty — workspace 별 자체 채움).
|
||||
|
||||
| 메트릭 | 정의 | 조건 |
|
||||
|--------|------|------|
|
||||
| `hit_at_k` | top-k 안 expected chunk 존재 여부 (binary). P(hit@k=true) 평균 | `expected_chunk_ids` 채움 |
|
||||
| `MRR` | Mean Reciprocal Rank (첫 관련 chunk rank 역수 평균) | `expected_chunk_ids` 채움 |
|
||||
| `recall_at_k_doc` | top-k 안 expected doc 비율 (`|top-k_docs ∩ expected_doc_ids| / |expected_doc_ids|`) | `expected_doc_ids` 채움 |
|
||||
| `precision_at_k_chunk` (fb-39) | top-k 안 chunk_id 가 `expected_chunk_ids` 에 포함된 비율. 분모 = k (fixed) — `top-k` 부족도 precision 손실로 간주. 빈 `expected_chunk_ids` query 는 skip. | `expected_chunk_ids` 채움 |
|
||||
|
||||
#### Groundedness metrics (rule-based)
|
||||
|
||||
| 메트릭 | 정의 |
|
||||
|--------|------|
|
||||
| `must_contain` pass | answer 문자열 이 `golden.must_contain` 의 모든 substring 포함 |
|
||||
| `forbidden` pass | answer 문자열 이 `golden.forbidden` 의 substring 미포함 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 동결 범위 / 변경 정책
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
---
|
||||
title: "p9-fb-39 — Eval foundation design (P@k metric)"
|
||||
phase: P9
|
||||
component: kebab-eval + docs
|
||||
task_id: p9-fb-39
|
||||
status: design
|
||||
target_version: 0.7.0
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§3 chunking, §4 search, §7 RAG, §11 eval]
|
||||
date: 2026-05-10
|
||||
---
|
||||
|
||||
# p9-fb-39 — Eval foundation (P@k metric)
|
||||
|
||||
## Goal
|
||||
|
||||
도그푸딩 피드백 — agent / 사용자가 "rank 5+ 부터 노이즈 섞임" 지적 (precision-at-k 저하). lever (chunk policy / RRF / score_gate / cross-encoder / embedding) 선택 전, **measurement infrastructure 먼저** 정비. 본 PR scope:
|
||||
|
||||
- `AggregateMetrics` 에 `precision_at_k_chunk: BTreeMap<u32, f32>` 추가 (P@5, P@10).
|
||||
- chunk-level binary relevance 기반 — `expected_chunk_ids` 안 chunk 가 top-k 안 등장한 비율.
|
||||
- Golden set schema 무변경 — `expected_chunk_ids` 가 ground truth (curator 책임).
|
||||
- 문서화 강화 — `fixtures/golden_queries.yaml` 헤더 주석.
|
||||
|
||||
Lever 적용 (chunk policy / RRF tune / cross-encoder / embedding upgrade) 은 **본 spec 범위 외** — fb-39b 이후 별도 task 로 분리. 측정 도구가 먼저 있어야 lever 효과 비교 가능.
|
||||
|
||||
## Behavior contract
|
||||
|
||||
### Metric definition
|
||||
|
||||
```
|
||||
P@k_chunk(query) = |top-k hits ∩ expected_chunk_ids| / k
|
||||
```
|
||||
|
||||
**Denominator = k 고정**. `hits.len() < k` 인 경우에도 분모는 k — top-k 부족도 precision 손실로 간주 (`hit_at_k` 와 동일 컨벤션).
|
||||
|
||||
`expected_chunk_ids` 빈 query 는 metric 계산에서 skip (`hit_at_k_chunk` 와 동일 정책).
|
||||
|
||||
**Aggregation**: 모든 valid query (expected_chunk_ids 비어있지 않음) 의 P@k_chunk 평균. valid query 0 건이면 NaN → JSON null.
|
||||
|
||||
### Wire shape
|
||||
|
||||
`AggregateMetrics` 신규 field:
|
||||
|
||||
```rust
|
||||
pub struct AggregateMetrics {
|
||||
pub hit_at_k: BTreeMap<u32, f32>,
|
||||
pub mrr: f32,
|
||||
pub recall_at_k_doc: BTreeMap<u32, f32>,
|
||||
/// p9-fb-39: chunk-level precision at k. Binary relevance via
|
||||
/// `expected_chunk_ids`. Denominator = k (fixed). Skip queries
|
||||
/// with empty `expected_chunk_ids`.
|
||||
#[serde(default)]
|
||||
pub precision_at_k_chunk: BTreeMap<u32, f32>,
|
||||
// ... 기존 필드 ...
|
||||
}
|
||||
```
|
||||
|
||||
`#[serde(default)]` — 기존 eval_runs.metrics_json (옛 binary 가 기록한) 에 field 부재 시 empty BTreeMap 로 deserialize. backwards-compat 보장.
|
||||
|
||||
### k values
|
||||
|
||||
`compute_aggregate_metrics` 가 5, 10 두 값에 대해 계산. (기존 `hit_at_k` / `recall_at_k_doc` 가 이미 동일 k 사용 — 재사용.)
|
||||
|
||||
## Allowed / forbidden dependencies
|
||||
|
||||
- `kebab-eval`: 신규 dep 없음. metrics 모듈 확장만.
|
||||
- 다른 crate 무수정.
|
||||
|
||||
`kebab-eval` 의 `metrics` / `compare` 모듈은 retrieval / embedding / LLM crate 직접 import 금지 룰 그대로 (P5 inheritance).
|
||||
|
||||
## Public surface delta
|
||||
|
||||
### kebab-eval::metrics
|
||||
|
||||
```rust
|
||||
pub struct AggregateMetrics {
|
||||
// ... 기존 ...
|
||||
#[serde(default)]
|
||||
pub precision_at_k_chunk: BTreeMap<u32, f32>,
|
||||
}
|
||||
```
|
||||
|
||||
`compute_aggregate_metrics` body 안 새 누적 BTreeMap + 평균 계산 추가. NaN handling 은 기존 `serialize_f32_nan_as_null` 패턴 재사용 — 단, BTreeMap<u32, f32> 의 NaN 처리 패턴이 hit_at_k 와 동일하게 round_recall_map 같은 helper 통해.
|
||||
|
||||
## Test plan
|
||||
|
||||
| kind | description |
|
||||
|------|-------------|
|
||||
| unit (metrics) | `precision_at_k_chunk` empty expected → query skip → metric BTreeMap 안 entry 부재 또는 NaN |
|
||||
| unit (metrics) | exact match: 5 hits, top-3 in expected → P@5 = 3/5 = 0.6 |
|
||||
| unit (metrics) | partial top-k: hits.len() = 3 < k=5, all 3 in expected → P@5 = 3/5 = 0.6 (분모 k 고정) |
|
||||
| unit (metrics) | top-k 안 expected 0건 → P@5 = 0.0 |
|
||||
| unit (metrics) | 모든 query expected 비어있음 → P@k entry 부재 또는 NaN → JSON null |
|
||||
| unit (metrics) | `AggregateMetrics` serde roundtrip — precision_at_k_chunk 신규 field 보존 |
|
||||
| unit (metrics) | 옛 JSON (precision_at_k_chunk 부재) deserialize → empty BTreeMap default |
|
||||
| 통합 (eval runner) | runner end-to-end → eval_runs.metrics_json 안 precision_at_k_chunk 채워짐 |
|
||||
|
||||
snapshot tests (기존 metrics 출력 fixture 가 있다면 갱신 — `cargo test -p kebab-eval` 수행 후 fixture diff 확인).
|
||||
|
||||
## Implementation steps (high-level)
|
||||
|
||||
1. `kebab-eval::metrics`: `AggregateMetrics.precision_at_k_chunk` field 추가 + 계산 로직 + 단위 테스트.
|
||||
2. snapshot tests 갱신 (있다면).
|
||||
3. `fixtures/golden_queries.yaml` 헤더 주석 강화 — `expected_chunk_ids` 채우기 가이드.
|
||||
4. README `kebab eval` 섹션 또는 design §11 eval 에 P@k 정의 한 줄 추가.
|
||||
5. tasks/INDEX.md / spec status flip.
|
||||
|
||||
3-5 step PR. 단일 세션 내 완료 가능.
|
||||
|
||||
## Risks / notes
|
||||
|
||||
- **분모 = k 고정 정책**: `hits.len() < k` 인 query 가 많으면 P@k 가 항상 < 1.0. 사용자 직관과 다를 수 있음 — README/design 에 명시.
|
||||
- **frozen design vs new metric**: design §11 eval 의 metric 표 갱신 필요. frozen contract 변경 트리거 — `target_version: 0.7.0` bump 명시.
|
||||
- **lever deferral**: 본 spec contract_sections 는 §3 chunking + §4 search + §7 RAG + §11 eval 인데, 실제 본 PR 은 §11 만 건드림. lever 적용 (chunk policy / RRF / cross-encoder / embedding) 은 fb-39b 이후 별도. spec status banner 에 명시.
|
||||
- **expected_chunk_ids 비어있는 shipped golden**: 현재 `fixtures/golden_queries.yaml` 의 g001-g005 모두 expected_chunk_ids 비어있음. P@k 계산 시 모두 skip — out-of-the-box 측정값 0건. curator 가 자기 KB 로 채워야 metric 의미 가짐. 의도 — golden set 은 workspace 의존이라 shipped fixtures 는 template, 실제 측정은 user 가 채워서 한다.
|
||||
- **fb-23 incremental ingest 와 충돌 없음**: 본 PR 은 metric 만 추가. chunker_version / embedding_version 무변경.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Lever 적용 (chunk policy retune / RRF k tune / score_gate default ON / cross-encoder PoC / embedding model 업그레이드).
|
||||
- NDCG / MAP / 기타 ranking metric.
|
||||
- precision_at_k_doc (doc-level — recall_at_k_doc 가 이미 있음, 본 spec 은 chunk-level 만).
|
||||
- Golden set 콘텐츠 확장 (g006+ 추가) — curator 책임.
|
||||
- Synthetic golden generator (`kebab eval golden-from-corpus` 등).
|
||||
- Per-query relevance score (binary 0/1 만 — graded relevance 는 NDCG 도입 시 검토).
|
||||
|
||||
## Documentation updates (implementation PR 동시)
|
||||
|
||||
- `fixtures/golden_queries.yaml` — 헤더 주석에 `expected_chunk_ids` ground truth 의미 + P@k 측정 위해 채우기 권장 안내.
|
||||
- `README.md` — `kebab eval` 섹션 (있다면) 에 P@k metric 한 줄. 없으면 skip.
|
||||
- `docs/superpowers/specs/2026-04-27-kebab-final-form-design.md` §11 eval — metric 표에 `precision_at_k_chunk` 한 줄 추가.
|
||||
- `tasks/p9/p9-fb-39-retrieval-precision-tuning.md` — `status: open → completed`, 단 banner 에 "eval foundation only, lever 적용 deferred to fb-39b" 명시 + design/plan 링크.
|
||||
- `tasks/INDEX.md` — fb-39 행 ✅ (eval foundation only).
|
||||
@@ -1,4 +1,4 @@
|
||||
# Golden query suite for `kb eval run` (P5-1 / P5-2).
|
||||
# Golden query suite for `kebab eval run` (P5-1 / P5-2 / fb-39).
|
||||
#
|
||||
# Top-level: list of queries. Required fields: `id`, `query`. All
|
||||
# others are optional and default to empty / null.
|
||||
@@ -7,8 +7,16 @@
|
||||
# real rows in the active workspace's SQLite store at run time. Stale
|
||||
# references make the runner bail at start. The shipped template
|
||||
# leaves them empty so the file is loadable on any fresh workspace —
|
||||
# fill them in after a `kb ingest` to enable hit@k / MRR metrics
|
||||
# (P5-2).
|
||||
# fill them in after a `kebab ingest` to enable the metrics that
|
||||
# require ground truth (P5-2 + fb-39):
|
||||
#
|
||||
# - `expected_chunk_ids` → hit_at_k, MRR, precision_at_k_chunk (fb-39)
|
||||
# - `expected_doc_ids` → recall_at_k_doc
|
||||
#
|
||||
# `precision_at_k_chunk` (fb-39): of the top-k retrieved hits, what
|
||||
# fraction's `chunk_id` is in `expected_chunk_ids`. Denominator is k
|
||||
# (fixed) — `top-k` shortfall is treated as precision loss. Queries
|
||||
# with empty `expected_chunk_ids` are skipped from this metric.
|
||||
#
|
||||
# `must_contain` / `forbidden` drive the rule-based groundedness
|
||||
# metric (P5-2).
|
||||
|
||||
@@ -129,7 +129,7 @@ P0~P5 는 직렬. P6~P9 는 P5 이후 병렬 가능.
|
||||
|
||||
### 🎯 0.5.0 — RAG quality (cascade 동반: V00X + reindex)
|
||||
- [p9-fb-38 score semantics](p9/p9-fb-38-score-semantics.md) — ✅ 머지 (2026-05-10)
|
||||
- [p9-fb-39 retrieval precision 튜닝](p9/p9-fb-39-retrieval-precision-tuning.md) — ⏳ 미구현, brainstorm 필요 (embedding_version cascade)
|
||||
- [p9-fb-39 retrieval precision 튜닝](p9/p9-fb-39-retrieval-precision-tuning.md) — ✅ 머지 (2026-05-10) — eval foundation only, lever 적용 deferred
|
||||
- [p9-fb-40 fact-grounded answer](p9/p9-fb-40-fact-grounded-answer.md) — ✅ 머지 (2026-05-10)
|
||||
|
||||
### 🎯 0.6.0 또는 P+ — reasoning
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
---
|
||||
phase: P9
|
||||
component: kebab-search + kebab-rag + kebab-chunk
|
||||
component: kebab-eval + docs
|
||||
task_id: p9-fb-39
|
||||
title: "Retrieval precision 튜닝 (rank 5+ 노이즈 완화)"
|
||||
status: open
|
||||
target_version: 0.5.0
|
||||
status: completed
|
||||
target_version: 0.7.0
|
||||
depends_on: []
|
||||
unblocks: []
|
||||
contract_source: ../../docs/superpowers/specs/2026-04-27-kebab-final-form-design.md
|
||||
contract_sections: [§3 chunking, §4 search, §7 RAG]
|
||||
contract_sections: [§3 chunking, §4 search, §7 RAG, §10.3 eval metrics]
|
||||
source_feedback: 사용자 도그푸딩 2026-05-06 — Claude Code 가 kebab CLI 사용 후 "rank 5+ 부터 노이즈 섞임" 지적. precision-at-k 가 k=5 이후 떨어짐.
|
||||
---
|
||||
|
||||
# p9-fb-39 — Retrieval precision 튜닝
|
||||
|
||||
> ⏳ **백로그 only — 미구현.** 본 spec 은 도그푸딩 피드백 skeleton. 구현 착수 전 [superpowers:brainstorming](../../docs/superpowers/) 으로 설계 단계 선행 필요. 어느 lever (chunk policy / RRF k / score gate / cross-encoder / embedding 업그레이드) 부터 손볼지, eval golden set 선행 여부 brainstorm 후 결정.
|
||||
> ✅ **Eval foundation 부분 구현 완료.** P@k metric (P@5, P@10) 추가. 본 spec 의 lever 적용 (chunk policy / RRF / cross-encoder / embedding 업그레이드) 은 별도 task 로 분리 (fb-39b 이후).
|
||||
>
|
||||
> - Design: [`docs/superpowers/specs/2026-05-10-p9-fb-39-eval-foundation-design.md`](../../docs/superpowers/specs/2026-05-10-p9-fb-39-eval-foundation-design.md)
|
||||
> - Plan: [`docs/superpowers/plans/2026-05-10-p9-fb-39-eval-foundation.md`](../../docs/superpowers/plans/2026-05-10-p9-fb-39-eval-foundation.md)
|
||||
|
||||
## 증상 / 동기
|
||||
|
||||
|
||||
Reference in New Issue
Block a user