kipimo 0.2.2__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kipimo
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Swahili agent-task evaluation suite for the East Africa coordination stack — model-agnostic seed benchmark (46 tasks: server routing, term grounding, cascade routing).
5
5
  Author: AfriKaziOS maintainers
6
6
  License-Expression: MIT
@@ -65,3 +65,29 @@ MIT-licensed harness, CC BY 4.0 data. Feedback via GitHub Issues only — pull r
65
65
 
66
66
  Model-agnostic by design: closed APIs, open-weight models, and small distilled models are all first-class citizens.
67
67
  <!-- /interconnect:v1 -->
68
+
69
+ ## Running a scorecard (`kipimo run`)
70
+
71
+ Fan out across targets in parallel, converge to one ranked scorecard. kipimo
72
+ still never calls a model API — you supply a *generator command* per target
73
+ (any process that reads tasks JSONL on stdin and writes predictions JSONL on
74
+ stdout; see `examples/generate_predictions.py`).
75
+
76
+ ```bash
77
+ cat > generators.json <<'JSON'
78
+ {
79
+ "gemma-3-12b": "python generate_predictions.py --model gemma-3-12b",
80
+ "inkubalm-0.4b": "python generate_predictions.py --model inkubalm-0.4b",
81
+ "kimi-k3": ""
82
+ }
83
+ JSON
84
+
85
+ kipimo run generators.json --timeout 600 --workers 4
86
+ ```
87
+
88
+ **An untested target is UNKNOWN, never zero.** If a generator crashes, times
89
+ out, or is unconfigured, that target appears under `untested` with a reason —
90
+ it is not ranked at 0.0. Conflating "the model failed the task" with "we never
91
+ tested the model" is how an evaluation starts measuring its own assumptions
92
+ instead of reality. Every scorecard also reports `coverage`, so a partial run
93
+ cannot be misread as a ranking of the field.
@@ -47,3 +47,29 @@ MIT-licensed harness, CC BY 4.0 data. Feedback via GitHub Issues only — pull r
47
47
 
48
48
  Model-agnostic by design: closed APIs, open-weight models, and small distilled models are all first-class citizens.
49
49
  <!-- /interconnect:v1 -->
50
+
51
+ ## Running a scorecard (`kipimo run`)
52
+
53
+ Fan out across targets in parallel, converge to one ranked scorecard. kipimo
54
+ still never calls a model API — you supply a *generator command* per target
55
+ (any process that reads tasks JSONL on stdin and writes predictions JSONL on
56
+ stdout; see `examples/generate_predictions.py`).
57
+
58
+ ```bash
59
+ cat > generators.json <<'JSON'
60
+ {
61
+ "gemma-3-12b": "python generate_predictions.py --model gemma-3-12b",
62
+ "inkubalm-0.4b": "python generate_predictions.py --model inkubalm-0.4b",
63
+ "kimi-k3": ""
64
+ }
65
+ JSON
66
+
67
+ kipimo run generators.json --timeout 600 --workers 4
68
+ ```
69
+
70
+ **An untested target is UNKNOWN, never zero.** If a generator crashes, times
71
+ out, or is unconfigured, that target appears under `untested` with a reason —
72
+ it is not ranked at 0.0. Conflating "the model failed the task" with "we never
73
+ tested the model" is how an evaluation starts measuring its own assumptions
74
+ instead of reality. Every scorecard also reports `coverage`, so a partial run
75
+ cannot be misread as a ranking of the field.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kipimo"
7
- version = "0.2.2"
7
+ version = "0.3.0"
8
8
  description = "Swahili agent-task evaluation suite for the East Africa coordination stack — model-agnostic seed benchmark (46 tasks: server routing, term grounding, cascade routing)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -8,3 +8,5 @@ from .targets import FAMILIES as FAMILIES
8
8
  from .targets import TARGETS as TARGETS
9
9
  from .targets import get_target as get_target
10
10
  from .targets import list_targets as list_targets
11
+ from .harness import run_scorecard as run_scorecard
12
+ from .harness import run_target as run_target
@@ -10,6 +10,7 @@ Usage:
10
10
  kipimo tasks # emit the task set (JSONL) to stdout
11
11
  kipimo template # emit an empty predictions file to fill in
12
12
  kipimo score preds.jsonl # score predictions against gold
13
+ kipimo run generators.json # run targets in parallel -> ranked scorecard
13
14
  kipimo targets # emit the scorecard target registry (v0.2)
14
15
  """
15
16
 
@@ -21,7 +22,7 @@ import sys
21
22
  from collections import defaultdict
22
23
  from importlib import resources
23
24
 
24
- __version__ = "0.2.0"
25
+ __version__ = "0.3.0"
25
26
 
26
27
  DISCLAIMER = ("kipimo v0.1 is a SEED benchmark (46 tasks). Swahili phrasing is "
27
28
  "simple-register and pending native-speaker review (issue #1). "
@@ -86,6 +87,10 @@ def main(argv: list[str] | None = None) -> int:
86
87
  tp.add_argument("--family", choices=("closed-api", "open-weight", "small-open"),
87
88
  default=None, help="filter by target family")
88
89
  sub.add_parser("template", help="emit empty predictions JSONL to stdout")
90
+ rp = sub.add_parser("run", help="run generators across targets in parallel -> scorecard")
91
+ rp.add_argument("generators", help='JSON file: {"target_id": "generator command", ...}')
92
+ rp.add_argument("--timeout", type=int, default=600, help="seconds per target (default 600)")
93
+ rp.add_argument("--workers", type=int, default=4, help="parallel targets (default 4)")
89
94
  sp = sub.add_parser("score", help="score a predictions file")
90
95
  sp.add_argument("predictions", help="JSONL with {id, prediction:[...]} rows")
91
96
  args = p.parse_args(argv)
@@ -100,6 +105,15 @@ def main(argv: list[str] | None = None) -> int:
100
105
  elif args.cmd == "template":
101
106
  for t in load_tasks():
102
107
  print(json.dumps({"id": t["id"], "prediction": []}))
108
+ elif args.cmd == "run":
109
+ from .harness import load_generators, run_scorecard
110
+ card = run_scorecard(load_generators(args.generators),
111
+ timeout=args.timeout, max_workers=args.workers)
112
+ print(json.dumps(card, indent=2, ensure_ascii=False))
113
+ if card["targets_untested"]:
114
+ print(f"\nNote: {card['targets_untested']} target(s) UNTESTED (unknown, not zero). "
115
+ f"See 'untested' in the scorecard.", file=sys.stderr)
116
+ print(f"\n{DISCLAIMER}", file=sys.stderr)
103
117
  else:
104
118
  rep = score_file(args.predictions)
105
119
  print(json.dumps(rep, indent=2))
@@ -0,0 +1,145 @@
1
+ """kipimo.harness — run the benchmark across targets in parallel, converge to a scorecard.
2
+
3
+ The diamond: fan out one worker per target, converge into a single ranked
4
+ scorecard. Targets are independent (a real dependency test — no target's
5
+ prediction informs another's), so they run concurrently; the scorecard is the
6
+ only true convergence point.
7
+
8
+ Two design rules, both deliberate:
9
+
10
+ 1. **kipimo still never calls a model API.** This module orchestrates *generator
11
+ commands you supply* (any process that reads tasks JSONL on stdin and writes
12
+ predictions JSONL on stdout). Model access, keys, and endpoints stay outside
13
+ the package, so the benchmark remains vendor-neutral and reproducible by
14
+ anyone. See examples/generate_predictions.py for a stdlib generator.
15
+
16
+ 2. **A missing run is never a zero.** The scoring path already treats an absent
17
+ prediction as 0.0, which is correct *within* a run. Across targets it would
18
+ be a lie: a target whose generator crashed, timed out, or was never
19
+ configured has an UNKNOWN score, not a bad one. Conflating "the model failed
20
+ the task" with "we never tested the model" is how an evaluation system starts
21
+ training on its own assumptions instead of on evidence. Every result
22
+ therefore carries an explicit ``status``:
23
+
24
+ ok — generator ran, predictions scored
25
+ error — generator failed (message retained)
26
+ timeout — generator exceeded its deadline
27
+ skipped — no generator configured for this target
28
+
29
+ Only ``ok`` results carry a score. The scorecard reports coverage alongside
30
+ results so a partial run can never be read as a complete one.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import concurrent.futures
35
+ import json
36
+ import shlex
37
+ import subprocess
38
+ import tempfile
39
+ import time
40
+ from pathlib import Path
41
+
42
+ from .cli import load_tasks, score_file
43
+ from .targets import get_target, list_targets
44
+
45
+ DEFAULT_TIMEOUT = 600 # seconds per target; generators are network-bound
46
+
47
+
48
+ def _tasks_jsonl() -> str:
49
+ return "\n".join(json.dumps(t, ensure_ascii=False) for t in load_tasks())
50
+
51
+
52
+ def run_target(target_id: str, command: str, timeout: int = DEFAULT_TIMEOUT) -> dict:
53
+ """Run one generator command and score its predictions.
54
+
55
+ ``command`` is a shell-style command that reads tasks JSONL on stdin and
56
+ writes predictions JSONL ({"id":..., "prediction":[...]}) on stdout.
57
+ Returns a result dict with an explicit status; never raises for generator
58
+ failure (a broken target must not abort the whole scorecard).
59
+ """
60
+ started = time.time()
61
+ base = {"target": target_id, "command": command}
62
+ try:
63
+ get_target(target_id) # validate against the registry
64
+ except Exception:
65
+ base["registry"] = "unregistered"
66
+ if not command:
67
+ return {**base, "status": "skipped", "score": None,
68
+ "reason": "no generator command configured"}
69
+ try:
70
+ proc = subprocess.run(
71
+ shlex.split(command), input=_tasks_jsonl(), text=True,
72
+ capture_output=True, timeout=timeout,
73
+ )
74
+ except subprocess.TimeoutExpired:
75
+ return {**base, "status": "timeout", "score": None,
76
+ "elapsed_s": round(time.time() - started, 1),
77
+ "reason": f"exceeded {timeout}s"}
78
+ except Exception as e: # generator binary missing, bad command, etc.
79
+ return {**base, "status": "error", "score": None,
80
+ "reason": f"{type(e).__name__}: {e}"}
81
+ if proc.returncode != 0:
82
+ return {**base, "status": "error", "score": None,
83
+ "reason": f"exit {proc.returncode}: {(proc.stderr or '').strip()[:200]}"}
84
+ if not proc.stdout.strip():
85
+ return {**base, "status": "error", "score": None,
86
+ "reason": "generator produced no predictions"}
87
+ with tempfile.NamedTemporaryFile("w", suffix=".jsonl", delete=False,
88
+ encoding="utf-8") as fh:
89
+ fh.write(proc.stdout)
90
+ path = fh.name
91
+ try:
92
+ report = score_file(path)
93
+ except Exception as e:
94
+ return {**base, "status": "error", "score": None,
95
+ "reason": f"unscoreable predictions: {type(e).__name__}: {e}"}
96
+ finally:
97
+ Path(path).unlink(missing_ok=True)
98
+ return {**base, "status": "ok", "score": report["overall"], "report": report,
99
+ "elapsed_s": round(time.time() - started, 1)}
100
+
101
+
102
+ def run_scorecard(generators: dict[str, str], timeout: int = DEFAULT_TIMEOUT,
103
+ max_workers: int = 4) -> dict:
104
+ """Fan out across targets, converge to one scorecard.
105
+
106
+ ``generators`` maps target_id -> generator command. Targets run in parallel
107
+ because they are genuinely independent; the scorecard is the convergence.
108
+ """
109
+ results: list[dict] = []
110
+ with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as pool:
111
+ futures = {pool.submit(run_target, tid, cmd, timeout): tid
112
+ for tid, cmd in generators.items()}
113
+ for fut in concurrent.futures.as_completed(futures):
114
+ results.append(fut.result())
115
+ ok = [r for r in results if r["status"] == "ok"]
116
+ ranked = sorted(ok, key=lambda r: r["score"], reverse=True)
117
+ untested = [r for r in results if r["status"] != "ok"]
118
+ registry_total = len(list_targets())
119
+ return {
120
+ "kipimo_tasks": len(load_tasks()),
121
+ "targets_attempted": len(results),
122
+ "targets_scored": len(ok),
123
+ "targets_untested": len(untested),
124
+ "registry_targets": registry_total,
125
+ # coverage makes an incomplete run impossible to misread as complete
126
+ "coverage": f"{len(ok)}/{registry_total} registry targets scored",
127
+ "ranked": [{"target": r["target"], "score": r["score"],
128
+ "by_type": {k: v for k, v in r["report"].items()
129
+ if k not in ("overall", "n_tasks", "n_missing")},
130
+ "n_missing": r["report"]["n_missing"]} for r in ranked],
131
+ "untested": [{"target": r["target"], "status": r["status"],
132
+ "reason": r.get("reason", "")} for r in untested],
133
+ "caveat": ("Scores are stack-routing competence, not general Swahili fluency. "
134
+ "Untested targets have UNKNOWN scores — not zero. A partial "
135
+ "scorecard is not a ranking of the field."),
136
+ }
137
+
138
+
139
+ def load_generators(path: str) -> dict[str, str]:
140
+ """Load a target_id -> command mapping from a JSON file."""
141
+ with open(path, encoding="utf-8") as f:
142
+ data = json.load(f)
143
+ if not isinstance(data, dict):
144
+ raise ValueError("generators file must be a JSON object: {target_id: command}")
145
+ return {str(k): str(v) for k, v in data.items()}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kipimo
3
- Version: 0.2.2
3
+ Version: 0.3.0
4
4
  Summary: Swahili agent-task evaluation suite for the East Africa coordination stack — model-agnostic seed benchmark (46 tasks: server routing, term grounding, cascade routing).
5
5
  Author: AfriKaziOS maintainers
6
6
  License-Expression: MIT
@@ -65,3 +65,29 @@ MIT-licensed harness, CC BY 4.0 data. Feedback via GitHub Issues only — pull r
65
65
 
66
66
  Model-agnostic by design: closed APIs, open-weight models, and small distilled models are all first-class citizens.
67
67
  <!-- /interconnect:v1 -->
68
+
69
+ ## Running a scorecard (`kipimo run`)
70
+
71
+ Fan out across targets in parallel, converge to one ranked scorecard. kipimo
72
+ still never calls a model API — you supply a *generator command* per target
73
+ (any process that reads tasks JSONL on stdin and writes predictions JSONL on
74
+ stdout; see `examples/generate_predictions.py`).
75
+
76
+ ```bash
77
+ cat > generators.json <<'JSON'
78
+ {
79
+ "gemma-3-12b": "python generate_predictions.py --model gemma-3-12b",
80
+ "inkubalm-0.4b": "python generate_predictions.py --model inkubalm-0.4b",
81
+ "kimi-k3": ""
82
+ }
83
+ JSON
84
+
85
+ kipimo run generators.json --timeout 600 --workers 4
86
+ ```
87
+
88
+ **An untested target is UNKNOWN, never zero.** If a generator crashes, times
89
+ out, or is unconfigured, that target appears under `untested` with a reason —
90
+ it is not ranked at 0.0. Conflating "the model failed the task" with "we never
91
+ tested the model" is how an evaluation starts measuring its own assumptions
92
+ instead of reality. Every scorecard also reports `coverage`, so a partial run
93
+ cannot be misread as a ranking of the field.
@@ -3,6 +3,7 @@ README.md
3
3
  pyproject.toml
4
4
  src/kipimo/__init__.py
5
5
  src/kipimo/cli.py
6
+ src/kipimo/harness.py
6
7
  src/kipimo/targets.py
7
8
  src/kipimo.egg-info/PKG-INFO
8
9
  src/kipimo.egg-info/SOURCES.txt
@@ -10,6 +11,7 @@ src/kipimo.egg-info/dependency_links.txt
10
11
  src/kipimo.egg-info/entry_points.txt
11
12
  src/kipimo.egg-info/top_level.txt
12
13
  src/kipimo/data/kipimo_v0.1.jsonl
14
+ tests/test_harness.py
13
15
  tests/test_kipimo.py
14
16
  tests/test_smoke.py
15
17
  tests/test_targets.py
@@ -0,0 +1,69 @@
1
+ """Harness tests — the load-bearing property is that UNTESTED is never scored as zero."""
2
+ import json
3
+ import sys
4
+
5
+ from kipimo.harness import run_target, run_scorecard
6
+
7
+ GOOD = f'{sys.executable} -c "import sys,json\nfor l in sys.stdin:\n l=l.strip()\n if l: t=json.loads(l); print(json.dumps({{\'id\':t[\'id\'],\'prediction\':[\'mpesa-mcp\']}}))"'
8
+ CRASH = f'{sys.executable} -c "import sys; sys.exit(3)"'
9
+ EMPTY = f'{sys.executable} -c "pass"'
10
+ HANG = f'{sys.executable} -c "import time; time.sleep(60)"'
11
+
12
+
13
+ def test_working_generator_scores():
14
+ r = run_target("gemma-3-12b", GOOD, timeout=60)
15
+ assert r["status"] == "ok"
16
+ assert isinstance(r["score"], float)
17
+ assert r["report"]["n_missing"] == 0
18
+
19
+
20
+ def test_crashed_generator_is_error_not_zero():
21
+ r = run_target("gemma-3-12b", CRASH, timeout=60)
22
+ assert r["status"] == "error"
23
+ assert r["score"] is None # NOT 0.0 — we did not test it
24
+ assert "exit 3" in r["reason"]
25
+
26
+
27
+ def test_empty_output_is_error_not_zero():
28
+ r = run_target("gemma-3-12b", EMPTY, timeout=60)
29
+ assert r["status"] == "error"
30
+ assert r["score"] is None
31
+ assert "no predictions" in r["reason"]
32
+
33
+
34
+ def test_timeout_is_recorded_not_zero():
35
+ r = run_target("gemma-3-12b", HANG, timeout=2)
36
+ assert r["status"] == "timeout"
37
+ assert r["score"] is None
38
+
39
+
40
+ def test_unconfigured_target_is_skipped():
41
+ r = run_target("kimi-k3", "", timeout=5)
42
+ assert r["status"] == "skipped"
43
+ assert r["score"] is None
44
+
45
+
46
+ def test_scorecard_separates_scored_from_untested():
47
+ card = run_scorecard({"gemma-3-12b": GOOD, "inkubalm-0.4b": CRASH, "kimi-k3": ""},
48
+ timeout=30, max_workers=3)
49
+ assert card["targets_scored"] == 1
50
+ assert card["targets_untested"] == 2
51
+ ranked_ids = [r["target"] for r in card["ranked"]]
52
+ assert ranked_ids == ["gemma-3-12b"] # only real results ranked
53
+ untested_ids = {r["target"] for r in card["untested"]}
54
+ assert untested_ids == {"inkubalm-0.4b", "kimi-k3"}
55
+ # coverage must make a partial run unmistakable
56
+ assert "/" in card["coverage"]
57
+ assert "not zero" in card["caveat"]
58
+
59
+
60
+ def test_one_broken_target_does_not_abort_the_run():
61
+ card = run_scorecard({"a": CRASH, "b": GOOD}, timeout=30, max_workers=2)
62
+ assert card["targets_attempted"] == 2
63
+ assert card["targets_scored"] == 1 # the good one still ran
64
+
65
+
66
+ def test_ranking_is_by_score_descending():
67
+ card = run_scorecard({"gemma-3-12b": GOOD, "qwen3-14b": GOOD}, timeout=30, max_workers=2)
68
+ scores = [r["score"] for r in card["ranked"]]
69
+ assert scores == sorted(scores, reverse=True)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes