sqbyl 0.1.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.
Files changed (54) hide show
  1. sqbyl-0.1.0/.gitignore +44 -0
  2. sqbyl-0.1.0/PKG-INFO +40 -0
  3. sqbyl-0.1.0/README.md +10 -0
  4. sqbyl-0.1.0/pyproject.toml +54 -0
  5. sqbyl-0.1.0/src/sqbyl/__init__.py +7 -0
  6. sqbyl-0.1.0/src/sqbyl/annotate.py +150 -0
  7. sqbyl-0.1.0/src/sqbyl/attention.py +209 -0
  8. sqbyl-0.1.0/src/sqbyl/calibration_io.py +87 -0
  9. sqbyl-0.1.0/src/sqbyl/candidates_io.py +66 -0
  10. sqbyl-0.1.0/src/sqbyl/cli.py +1717 -0
  11. sqbyl-0.1.0/src/sqbyl/coach.py +442 -0
  12. sqbyl-0.1.0/src/sqbyl/console/__init__.py +14 -0
  13. sqbyl-0.1.0/src/sqbyl/console/app.py +366 -0
  14. sqbyl-0.1.0/src/sqbyl/console/static/index.html +443 -0
  15. sqbyl-0.1.0/src/sqbyl/estimates.py +204 -0
  16. sqbyl-0.1.0/src/sqbyl/eval/__init__.py +10 -0
  17. sqbyl-0.1.0/src/sqbyl/eval/benchmarks_io.py +112 -0
  18. sqbyl-0.1.0/src/sqbyl/eval/comparator.py +158 -0
  19. sqbyl-0.1.0/src/sqbyl/eval/heldout.py +26 -0
  20. sqbyl-0.1.0/src/sqbyl/eval/judges.py +324 -0
  21. sqbyl-0.1.0/src/sqbyl/eval/report.py +89 -0
  22. sqbyl-0.1.0/src/sqbyl/eval/runner.py +250 -0
  23. sqbyl-0.1.0/src/sqbyl/eval/scorers.py +182 -0
  24. sqbyl-0.1.0/src/sqbyl/eval/selection.py +189 -0
  25. sqbyl-0.1.0/src/sqbyl/importers.py +373 -0
  26. sqbyl-0.1.0/src/sqbyl/init.py +573 -0
  27. sqbyl-0.1.0/src/sqbyl/introspect.py +177 -0
  28. sqbyl-0.1.0/src/sqbyl/kpis.py +124 -0
  29. sqbyl-0.1.0/src/sqbyl/llm.py +53 -0
  30. sqbyl-0.1.0/src/sqbyl/models/__init__.py +120 -0
  31. sqbyl-0.1.0/src/sqbyl/models/attention.py +141 -0
  32. sqbyl-0.1.0/src/sqbyl/models/benchmarks.py +38 -0
  33. sqbyl-0.1.0/src/sqbyl/models/candidates.py +152 -0
  34. sqbyl-0.1.0/src/sqbyl/models/coach.py +149 -0
  35. sqbyl-0.1.0/src/sqbyl/models/feedback.py +44 -0
  36. sqbyl-0.1.0/src/sqbyl/models/judges.py +61 -0
  37. sqbyl-0.1.0/src/sqbyl/models/kpis.py +99 -0
  38. sqbyl-0.1.0/src/sqbyl/models/manifest.py +133 -0
  39. sqbyl-0.1.0/src/sqbyl/models/optimize.py +126 -0
  40. sqbyl-0.1.0/src/sqbyl/models/runs.py +363 -0
  41. sqbyl-0.1.0/src/sqbyl/optimize.py +297 -0
  42. sqbyl-0.1.0/src/sqbyl/orchestrator.py +357 -0
  43. sqbyl-0.1.0/src/sqbyl/profile.py +219 -0
  44. sqbyl-0.1.0/src/sqbyl/project.py +126 -0
  45. sqbyl-0.1.0/src/sqbyl/projectfiles.py +87 -0
  46. sqbyl-0.1.0/src/sqbyl/py.typed +0 -0
  47. sqbyl-0.1.0/src/sqbyl/release.py +196 -0
  48. sqbyl-0.1.0/src/sqbyl/semantics_io.py +123 -0
  49. sqbyl-0.1.0/src/sqbyl/serve.py +441 -0
  50. sqbyl-0.1.0/src/sqbyl/state/__init__.py +7 -0
  51. sqbyl-0.1.0/src/sqbyl/state/hashing.py +69 -0
  52. sqbyl-0.1.0/src/sqbyl/stats.py +75 -0
  53. sqbyl-0.1.0/src/sqbyl/synth.py +371 -0
  54. sqbyl-0.1.0/src/sqbyl/yamlio.py +68 -0
sqbyl-0.1.0/.gitignore ADDED
@@ -0,0 +1,44 @@
1
+ # Claude Code / local agent workspace (designer-dev only, not part of the project)
2
+ .claude/
3
+
4
+ # sqbyl local state: runs, traces, usage, caches (per design spec §4)
5
+ .sqbyl/
6
+
7
+ # Python
8
+ __pycache__/
9
+ *.py[cod]
10
+ *$py.class
11
+ *.egg-info/
12
+ .eggs/
13
+ build/
14
+ dist/
15
+ *.egg
16
+
17
+ # uv / virtualenvs
18
+ .venv/
19
+ venv/
20
+ env/
21
+ .python-version
22
+
23
+ # Tooling caches
24
+ .pytest_cache/
25
+ .ruff_cache/
26
+ .mypy_cache/
27
+ .pyright/
28
+ .coverage
29
+ htmlcov/
30
+ .tox/
31
+
32
+ # Environment / secrets (never commit ANTHROPIC_API_KEY or DATABASE_URL)
33
+ .env
34
+ .env.*
35
+ !.env.example
36
+
37
+ # OS / editor
38
+ .DS_Store
39
+ .idea/
40
+ .vscode/
41
+ *.swp
42
+
43
+ # Private planning doc — kept local, not published
44
+ sqbyl-enhancements.md
sqbyl-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: sqbyl
3
+ Version: 0.1.0
4
+ Summary: The full sqbyl dev toolkit: introspect, profile, annotate, synth, eval, Coach, judges, console, optimizer, release builder.
5
+ Project-URL: Homepage, https://github.com/jackwerner/sqbyl
6
+ Project-URL: Repository, https://github.com/jackwerner/sqbyl
7
+ Project-URL: Issues, https://github.com/jackwerner/sqbyl/issues
8
+ Author: Jack Werner
9
+ License-Expression: MIT
10
+ Keywords: agent,anthropic,claude,evaluation,llm,sql,text-to-sql
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Database
17
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
18
+ Classifier: Typing :: Typed
19
+ Requires-Python: >=3.11
20
+ Requires-Dist: fastapi>=0.139.0
21
+ Requires-Dist: pydantic<3,>=2.7
22
+ Requires-Dist: pyyaml>=6
23
+ Requires-Dist: sqbyl-runtime==0.1.0
24
+ Requires-Dist: uvicorn>=0.50.2
25
+ Provides-Extra: anthropic
26
+ Requires-Dist: sqbyl-runtime[anthropic]==0.1.0; extra == 'anthropic'
27
+ Provides-Extra: openai
28
+ Requires-Dist: sqbyl-runtime[openai]==0.1.0; extra == 'openai'
29
+ Description-Content-Type: text/markdown
30
+
31
+ # sqbyl
32
+
33
+ The full text-to-SQL dev toolkit. See the [repository root README](../../README.md)
34
+ for the product overview, and `docs/sqbyl-design-spec.md` /
35
+ `docs/sqbyl-implementation-plan.md` for the design and build sequence.
36
+
37
+ This package contains the dev machinery — introspect, profile, annotate, synth,
38
+ the eval harness, the Coach, LLM judges, the review console, the orchestrator,
39
+ the optimizer, and the release builder. It depends on `sqbyl-runtime`
40
+ (never the reverse).
sqbyl-0.1.0/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # sqbyl
2
+
3
+ The full text-to-SQL dev toolkit. See the [repository root README](../../README.md)
4
+ for the product overview, and `docs/sqbyl-design-spec.md` /
5
+ `docs/sqbyl-implementation-plan.md` for the design and build sequence.
6
+
7
+ This package contains the dev machinery — introspect, profile, annotate, synth,
8
+ the eval harness, the Coach, LLM judges, the review console, the orchestrator,
9
+ the optimizer, and the release builder. It depends on `sqbyl-runtime`
10
+ (never the reverse).
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "sqbyl"
3
+ version = "0.1.0"
4
+ description = "The full sqbyl dev toolkit: introspect, profile, annotate, synth, eval, Coach, judges, console, optimizer, release builder."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11"
7
+ license = "MIT"
8
+ authors = [{ name = "Jack Werner" }]
9
+ keywords = ["text-to-sql", "sql", "llm", "claude", "anthropic", "agent", "evaluation"]
10
+ classifiers = [
11
+ "Development Status :: 4 - Beta",
12
+ "Intended Audience :: Developers",
13
+ "Programming Language :: Python :: 3",
14
+ "Programming Language :: Python :: 3.11",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Topic :: Database",
17
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
18
+ "Typing :: Typed",
19
+ ]
20
+ dependencies = [
21
+ # Lockstep: the toolkit and runtime are cut together, so pin the exact runtime
22
+ # this toolkit was built and tested against.
23
+ "sqbyl-runtime==0.1.0",
24
+ "pydantic>=2.7,<3",
25
+ "pyyaml>=6",
26
+ # The review console (spec §6.5) is a local FastAPI app with a bundled UI. It lives
27
+ # in the dev toolkit only — the shipped `sqbyl-runtime` never carries a web stack.
28
+ "fastapi>=0.139.0",
29
+ "uvicorn>=0.50.2",
30
+ ]
31
+
32
+ [project.optional-dependencies]
33
+ # Provider SDKs are optional and provider-neutral (see sqbyl-runtime): pick one and install
34
+ # its extra alongside the toolkit, e.g. `pip install sqbyl[openai]`.
35
+ anthropic = ["sqbyl-runtime[anthropic]==0.1.0"]
36
+ openai = ["sqbyl-runtime[openai]==0.1.0"]
37
+
38
+ [project.scripts]
39
+ sqbyl = "sqbyl.cli:main"
40
+
41
+ [project.urls]
42
+ Homepage = "https://github.com/jackwerner/sqbyl"
43
+ Repository = "https://github.com/jackwerner/sqbyl"
44
+ Issues = "https://github.com/jackwerner/sqbyl/issues"
45
+
46
+ [build-system]
47
+ requires = ["hatchling"]
48
+ build-backend = "hatchling.build"
49
+
50
+ [tool.uv.sources]
51
+ sqbyl-runtime = { workspace = true }
52
+
53
+ [tool.hatch.build.targets.wheel]
54
+ packages = ["src/sqbyl"]
@@ -0,0 +1,7 @@
1
+ """sqbyl — the full text-to-SQL dev toolkit.
2
+
3
+ Build, evaluate, and iterate on a Claude-powered text-to-SQL agent over your own
4
+ database. Depends on ``sqbyl_runtime`` (never the reverse).
5
+ """
6
+
7
+ __version__ = "0.0.0"
@@ -0,0 +1,150 @@
1
+ """The annotator — ``sqbyl annotate`` (spec §3 #1, plan 2.3).
2
+
3
+ Claude drafts table/column **descriptions and synonyms**, grounded in the Phase 1.3
4
+ **profile** (ranges, distinct counts, sample values) rather than guessing from
5
+ names — it can see that ``amount_cents`` spans 127..310229 with no nulls and infer
6
+ the cents unit, that ``status`` has three values, that ``created_at`` covers
7
+ 2019→today. Each annotation carries a **confidence** the attention router consumes
8
+ later (Phase 6).
9
+
10
+ This is dev-only authoring, so it lives in ``sqbyl`` and depends on the runtime's
11
+ LLM seam. It is per-table (parallel fan-out is Phase 6; here it is sequential). As
12
+ a paid command it is metered through the cost stub from day one (invariant 5).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ from sqbyl_runtime.llm.base import LLMClient, LLMRequest, LLMResponse, Message
20
+ from sqbyl_runtime.models import Column, Profile, TableSemantics
21
+ from sqbyl_runtime.state.traces import TraceWriter, llm_call_span, new_trace_id
22
+
23
+ _SYSTEM = (
24
+ "You are a data analyst documenting a SQL table for a text-to-SQL agent. "
25
+ "Write concise, factual descriptions grounded in the COLUMN PROFILE STATISTICS "
26
+ "provided (ranges, distinct counts, sample values) — infer units and meaning from "
27
+ "the data, never invent facts you can't support. Add a few natural-language "
28
+ "synonyms a user might say. Give each description a confidence in [0,1]: high when "
29
+ "the data makes the meaning obvious, low when the column is cryptic."
30
+ )
31
+
32
+
33
+ class ColumnAnnotation(BaseModel):
34
+ """A drafted description for one column, with the router's confidence signal."""
35
+
36
+ name: str
37
+ description: str = Field(description="One factual sentence, grounded in the profile.")
38
+ synonyms: list[str] = Field(default_factory=list)
39
+ confidence: float = Field(ge=0.0, le=1.0)
40
+
41
+
42
+ class TableAnnotation(BaseModel):
43
+ """A drafted description for a table and its columns."""
44
+
45
+ description: str = Field(description="What one row represents, grounded in the data.")
46
+ synonyms: list[str] = Field(default_factory=list)
47
+ confidence: float = Field(ge=0.0, le=1.0)
48
+ columns: list[ColumnAnnotation] = Field(default_factory=list)
49
+
50
+
51
+ def annotate_table(
52
+ llm: LLMClient,
53
+ table: TableSemantics,
54
+ *,
55
+ model: str,
56
+ trace_writer: TraceWriter | None = None,
57
+ trace_id: str | None = None,
58
+ parent_span_id: str | None = None,
59
+ ) -> tuple[TableAnnotation, LLMResponse]:
60
+ """Draft a description/synonyms for a table and its columns, grounded in the profile.
61
+
62
+ Like the agent pipeline, this builds the request explicitly so the token-spending
63
+ call can be written as an OTel-GenAI span when a ``trace_writer`` is given
64
+ (invariant 7). ``max_tokens`` matches the seam's structured default so existing
65
+ cassettes stay valid.
66
+ """
67
+ request = LLMRequest(
68
+ model=model,
69
+ messages=[Message(role="user", content=_render_for_annotation(table))],
70
+ system=_SYSTEM,
71
+ response_schema=TableAnnotation.model_json_schema(),
72
+ max_tokens=4096,
73
+ temperature=0.0,
74
+ cache_system=True,
75
+ )
76
+ response = llm.complete(request)
77
+ if trace_writer is not None:
78
+ trace_writer.write(
79
+ llm_call_span(
80
+ request,
81
+ response,
82
+ operation="chat",
83
+ name=f"annotate {table.table}",
84
+ trace_id=trace_id or new_trace_id(),
85
+ parent_span_id=parent_span_id,
86
+ )
87
+ )
88
+ return response.parse(TableAnnotation), response
89
+
90
+
91
+ def apply_annotation(table: TableSemantics, annotation: TableAnnotation) -> TableSemantics:
92
+ """Merge drafted descriptions/synonyms onto a table, preserving its profile.
93
+
94
+ Only ``description`` and ``synonyms`` are written (the durable authoring fields);
95
+ profiling stays as profiled. Columns are matched by name; unknown names are ignored.
96
+ """
97
+ by_name = {c.name: c for c in annotation.columns}
98
+ columns = [
99
+ col.model_copy(
100
+ update={
101
+ "description": by_name[col.name].description,
102
+ "synonyms": by_name[col.name].synonyms,
103
+ }
104
+ )
105
+ if col.name in by_name
106
+ else col
107
+ for col in table.columns
108
+ ]
109
+ return table.model_copy(
110
+ update={
111
+ "description": annotation.description,
112
+ "synonyms": annotation.synonyms,
113
+ "columns": columns,
114
+ }
115
+ )
116
+
117
+
118
+ def _render_for_annotation(table: TableSemantics) -> str:
119
+ """Expose the raw profile so the model grounds its descriptions in data."""
120
+ lines = [f"Table: {table.table}", "", "Columns (name, type, profile):"]
121
+ for col in table.columns:
122
+ lines.append(f"- {col.name} ({col.type}){_profile_summary(col)}")
123
+ if table.joins:
124
+ lines.append("")
125
+ lines.append("Joins:")
126
+ for join in table.joins:
127
+ lines.append(f"- {join.type} -> {join.to} ON {join.on}")
128
+ lines.append("")
129
+ lines.append(
130
+ "Draft a table description (what one row is) plus a description and synonyms for "
131
+ "every column, each with a confidence. Return them all."
132
+ )
133
+ return "\n".join(lines)
134
+
135
+
136
+ def _profile_summary(col: Column) -> str:
137
+ if col.sample_values:
138
+ return f" values: {', '.join(str(v) for v in col.sample_values)}"
139
+ if isinstance(col.profile, Profile):
140
+ p = col.profile
141
+ bits = []
142
+ if p.nulls is not None:
143
+ bits.append(f"nulls={p.nulls}")
144
+ if p.distinct is not None:
145
+ bits.append(f"distinct={p.distinct}")
146
+ if p.min is not None and p.max is not None:
147
+ bits.append(f"range={p.min}..{p.max}")
148
+ if bits:
149
+ return " " + ", ".join(bits)
150
+ return ""
@@ -0,0 +1,209 @@
1
+ """The attention router + readiness scorer (spec §3 #9, §5.5, plan 6.2).
2
+
3
+ Given a bag of scored :class:`Decision`s, this:
4
+
5
+ 1. **auto-applies** the high-confidence, machine-decidable ones (confidence ≥
6
+ :data:`AUTO_APPLY_THRESHOLD` and not ``requires_human``) — the console shows them
7
+ collapsed/done with one-click undo;
8
+ 2. **surfaces the rest** into a single queue **sorted by leverage** (highest first — the
9
+ fewest decisions that move readiness the most);
10
+ 3. computes the **readiness signal**: current accuracy with its interval, and how many queued
11
+ decisions it takes to reach the project's target.
12
+
13
+ The adapters turn the artifacts sqbyl already produces — a Coach report, an eval's review
14
+ pile, an orchestrator run's failed units — into decisions, so the queue is populated from
15
+ real work rather than hand-built. Dev-only review machinery, so it lives in ``sqbyl``.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from typing import TYPE_CHECKING
21
+
22
+ from sqbyl.models.attention import AttentionQueue, Decision, DecisionKind, ReadinessSignal
23
+ from sqbyl.stats import wilson_interval
24
+
25
+ if TYPE_CHECKING:
26
+ from sqbyl.models.coach import CoachReport
27
+ from sqbyl.models.runs import QuestionResult, ScoredRun
28
+ from sqbyl.orchestrator import OrchestratorResult
29
+
30
+ # Default confidence at/above which a machine decision is applied without asking (one-click
31
+ # undo). An unvalidated heuristic, like the judge's DEFAULT_MIN_CONFIDENCE — still needs
32
+ # re-deriving against human accept/reject rates once the console logs them. Overridable per
33
+ # project via ``DefaultsConfig.auto_apply_threshold`` (set 1.0 to require a human on all).
34
+ AUTO_APPLY_THRESHOLD = 0.85
35
+
36
+
37
+ def _leverage_sort_key(d: Decision) -> tuple[float, float, str]:
38
+ """Highest leverage first; ties broken by *lower* confidence (more ambiguous → look sooner),
39
+ then id for a stable order."""
40
+ return (-d.leverage, d.confidence, d.id)
41
+
42
+
43
+ def route(
44
+ decisions: list[Decision],
45
+ *,
46
+ n_correct: int,
47
+ n: int,
48
+ target: float,
49
+ auto_apply_threshold: float = AUTO_APPLY_THRESHOLD,
50
+ ) -> AttentionQueue:
51
+ """Split ``decisions`` into auto-applied vs. a leverage-sorted queue + a readiness signal.
52
+
53
+ ``n_correct``/``n`` are the eval's exact deterministic counts (passed as integers, not a
54
+ reconstructed float, so the interval is honest); ``target`` is the shippable bar from the
55
+ manifest; ``auto_apply_threshold`` gates unsighted apply (the manifest's
56
+ ``auto_apply_threshold`` knob — 1.0 requires a human on everything).
57
+ """
58
+
59
+ def auto(d: Decision) -> bool:
60
+ return d.auto_applyable and d.confidence >= auto_apply_threshold
61
+
62
+ auto_applied = [d for d in decisions if auto(d)]
63
+ queue = sorted((d for d in decisions if not auto(d)), key=_leverage_sort_key)
64
+ readiness = _readiness(queue, n_correct=n_correct, n=n, target=target)
65
+ return AttentionQueue(auto_applied=auto_applied, queue=queue, readiness=readiness)
66
+
67
+
68
+ def _readiness(queue: list[Decision], *, n_correct: int, n: int, target: float) -> ReadinessSignal:
69
+ """Greedy 'fewest decisions to target': walk the queue high-leverage first, accumulating
70
+ *de-duplicated* estimated leverage until it clears ``target``.
71
+
72
+ Leverage is summed across picked decisions, but two decisions that both claim the same
73
+ question can't fix it twice — so a decision's contribution is capped by the fraction of the
74
+ eval its *newly-covered* questions represent (union, not naive sum). The result is a
75
+ projection built on unverified estimates; :class:`ReadinessSignal` labels it as such."""
76
+ accuracy = n_correct / n if n else 0.0
77
+ low, high = wilson_interval(n_correct, n)
78
+ base = ReadinessSignal(
79
+ accuracy=accuracy, target=target, n=n, accuracy_low=low, accuracy_high=high
80
+ )
81
+
82
+ if accuracy >= target:
83
+ base.decisions_to_target = 0
84
+ base.projected = accuracy
85
+ base.reachable = True
86
+ return base
87
+
88
+ projected = accuracy
89
+ count = 0
90
+ covered: set[str] = set() # questions already claimed — never credited twice
91
+ for d in queue:
92
+ if projected >= target:
93
+ break
94
+ if d.leverage <= 0.0:
95
+ # A blocked/judge card (leverage 0) is surfaced but never closes the gap —
96
+ # it's absence-of-result, not a gain (the 6.1 contract on failed units).
97
+ continue
98
+ gain = d.leverage
99
+ if d.affected_questions:
100
+ new_q = [q for q in d.affected_questions if q not in covered]
101
+ if not new_q:
102
+ continue # fully overlaps an earlier pick — contributes nothing new
103
+ gain = min(d.leverage, len(new_q) / n) if n else 0.0
104
+ covered.update(d.affected_questions)
105
+ projected = min(1.0, projected + gain)
106
+ count += 1
107
+
108
+ base.decisions_to_target = count
109
+ base.projected = projected
110
+ base.reachable = projected >= target
111
+ return base
112
+
113
+
114
+ # ── adapters: real artifacts → decisions ────────────────────────────────────────────────
115
+
116
+ _LAYER_TO_KIND: dict[str, DecisionKind] = {
117
+ "example": DecisionKind.example,
118
+ "measure": DecisionKind.measure,
119
+ "synonym": DecisionKind.synonym,
120
+ "named_filter": DecisionKind.named_filter,
121
+ "column_description": DecisionKind.column_description,
122
+ "table_description": DecisionKind.table_description,
123
+ "instruction": DecisionKind.instruction,
124
+ "trusted_asset": DecisionKind.example,
125
+ }
126
+
127
+
128
+ def decisions_from_coach_report(report: CoachReport, *, total: int) -> list[Decision]:
129
+ """Turn Coach proposals into decisions. Leverage is ``predicted_fixes / total`` (accuracy
130
+ points), an estimate; prose and memorization-risk proposals are forced to require a human
131
+ (they never auto-apply), consistent with the Coach's own ranking (spec §8)."""
132
+ decisions: list[Decision] = []
133
+ for p in report.proposals:
134
+ leverage = (p.predicted_fixes / total) if total else 0.0
135
+ # Prose (the last-resort layer) and single-question memorization-risk examples never
136
+ # auto-apply — the schema owns "is this prose" via CoachProposal.is_prose (invariant 2).
137
+ requires_human = p.is_prose or p.memorization_risk
138
+ decisions.append(
139
+ Decision(
140
+ id=f"coach:{p.id}",
141
+ kind=_LAYER_TO_KIND.get(p.layer.value, DecisionKind.instruction),
142
+ title=p.title,
143
+ detail=p.root_cause,
144
+ suggestion=p.render_diff(),
145
+ confidence=p.confidence,
146
+ leverage=leverage,
147
+ affected_questions=list(p.question_ids),
148
+ source=f"coach:{p.id}",
149
+ target_file=p.target_file,
150
+ requires_human=requires_human,
151
+ )
152
+ )
153
+ return decisions
154
+
155
+
156
+ def decisions_from_review_pile(run: ScoredRun) -> list[Decision]:
157
+ """Turn an eval's ``manual_review`` rows into judge-review cards — always the human's call.
158
+
159
+ These never auto-apply (``requires_human``) and carry no leverage: a review ruling resolves
160
+ a *reported* number, it doesn't teach the agent, so it must not inflate readiness."""
161
+ decisions: list[Decision] = []
162
+ for r in run.results:
163
+ if not r.needs_review:
164
+ continue
165
+ suggestion = r.judge_suggestion.value if r.judge_suggestion is not None else ""
166
+ confidence = _least_judge_confidence(r)
167
+ decisions.append(
168
+ Decision(
169
+ id=f"judge:{run.run_id}:{r.id}",
170
+ kind=DecisionKind.judge_review,
171
+ title=f"Review {r.id}",
172
+ detail=r.question,
173
+ suggestion=suggestion,
174
+ confidence=confidence,
175
+ leverage=0.0,
176
+ affected_questions=[r.id],
177
+ source=f"judge:{r.id}",
178
+ requires_human=True,
179
+ )
180
+ )
181
+ return decisions
182
+
183
+
184
+ def decisions_from_outcomes(result: OrchestratorResult[object]) -> list[Decision]:
185
+ """Turn orchestrator outcomes into cards: a **failed** unit becomes a blocked card the
186
+ human must retry/handle (spec §3 #8) — never auto-applied and carrying no leverage, so it
187
+ surfaces without pretending to be a weak result. Skipped (budget) units aren't cards."""
188
+ decisions: list[Decision] = []
189
+ for o in result.failures:
190
+ decisions.append(
191
+ Decision(
192
+ id=f"blocked:{o.unit.id}",
193
+ kind=DecisionKind.blocked,
194
+ title=o.unit.label or o.unit.id,
195
+ detail=o.error or "unit failed",
196
+ confidence=0.0,
197
+ leverage=0.0,
198
+ source=o.unit.id,
199
+ requires_human=True,
200
+ )
201
+ )
202
+ return decisions
203
+
204
+
205
+ def _least_judge_confidence(result: QuestionResult) -> float:
206
+ """The least-sure judge on a row — a rough 'how ambiguous is this' signal for ordering."""
207
+ if not result.judge_verdicts:
208
+ return 0.0
209
+ return min(v.confidence for v in result.judge_verdicts)
@@ -0,0 +1,87 @@
1
+ """The judge calibration set — ``.sqbyl/calibration.jsonl`` (spec §7, plan 5.2).
2
+
3
+ Every time a human confirms or overrides a judged row in the review console, we append a
4
+ :class:`CalibrationRecord` here: what the judge suggested, what the human decided, and
5
+ whether they agreed. Accumulated, these give the live **judge↔human agreement** score —
6
+ the number that tells a curator how far to trust the judge on rows nobody has reviewed
7
+ (standard inter-rater agreement between the LLM judge and human reviewers).
8
+
9
+ Append-only JSONL: the calibration set is an audit trail, not mutable state, so a review
10
+ is never silently rewritten. This is dev-loop data (it only exists because a human is
11
+ reviewing benchmarks), so the path is derived here rather than in the runtime layout.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ from sqbyl.models import CalibrationRecord, JudgeAgreement
19
+ from sqbyl.project import Project
20
+ from sqbyl_runtime.state.layout import SqbylPaths
21
+
22
+
23
+ def calibration_path(project: Project) -> Path:
24
+ return SqbylPaths(project.root).root / "calibration.jsonl"
25
+
26
+
27
+ def load_calibration(project: Project) -> list[CalibrationRecord]:
28
+ """Every recorded review, oldest first. Empty when nothing has been reviewed yet."""
29
+ path = calibration_path(project)
30
+ if not path.exists():
31
+ return []
32
+ records = [
33
+ CalibrationRecord.model_validate_json(line)
34
+ for line in path.read_text().splitlines()
35
+ if line.strip()
36
+ ]
37
+ return records
38
+
39
+
40
+ def append_calibration(project: Project, record: CalibrationRecord) -> None:
41
+ """Append one review to the calibration trail (creates the file/dir on first write)."""
42
+ path = calibration_path(project)
43
+ path.parent.mkdir(parents=True, exist_ok=True)
44
+ with path.open("a") as fh:
45
+ fh.write(record.model_dump_json() + "\n")
46
+
47
+
48
+ def _latest_per_row(records: list[CalibrationRecord]) -> list[CalibrationRecord]:
49
+ """Keep only the last ruling for each ``(run_id, question_id)``, in first-seen order.
50
+
51
+ A human who re-opens and re-resolves a row appends a fresh record (the JSONL stays an
52
+ append-only audit trail), but only their latest call should count — otherwise a repeated
53
+ click would double-count in the agreement rate and duplicate a few-shot anchor."""
54
+ latest: dict[tuple[str, str], CalibrationRecord] = {}
55
+ for r in records:
56
+ latest[(r.run_id, r.question_id)] = r # later write wins
57
+ return list(latest.values())
58
+
59
+
60
+ def judge_agreement(project: Project, *, split: str | None = None) -> JudgeAgreement:
61
+ """The live judge↔human agreement, deduped to each row's latest ruling (spec §7).
62
+
63
+ Optionally scoped to one split. Selection-biased by construction — see
64
+ :class:`~sqbyl.models.JudgeAgreement`."""
65
+ records = load_calibration(project)
66
+ if split is not None:
67
+ records = [r for r in records if r.split == split]
68
+ return JudgeAgreement.from_records(_latest_per_row(records))
69
+
70
+
71
+ # How many prior human rulings to replay to the judge as few-shot examples. Small: a few
72
+ # concrete anchors coach without ballooning every judge prompt (and cost).
73
+ FEW_SHOT_LIMIT = 3
74
+
75
+
76
+ def few_shot_examples(
77
+ project: Project, *, split: str, limit: int = FEW_SHOT_LIMIT
78
+ ) -> list[CalibrationRecord]:
79
+ """The most recent human rulings **for one split**, to coach that split's judge (spec §7).
80
+
81
+ Split-scoped so dev rulings never coach the held-out test judge (invariant 3), and deduped
82
+ to each row's latest ruling. Recency, not cherry-picking: the last ``limit`` reviews (both
83
+ agreements and overrides) so the judge sees honest ground-truth anchors, not a
84
+ disagreement-biased sample. Empty until a human has reviewed something on this split — so
85
+ an un-reviewed project's judge prompts, and the CI cassettes, are unchanged."""
86
+ records = [r for r in load_calibration(project) if r.split == split]
87
+ return _latest_per_row(records)[-limit:]
@@ -0,0 +1,66 @@
1
+ """The synth → review candidate queue on disk (``.sqbyl/candidates.yaml``).
2
+
3
+ ``sqbyl synth`` writes survivors here; the review console reads them, shows the executed
4
+ evidence, and on accept promotes a candidate into ``benchmarks/dev.yaml`` (via
5
+ :func:`sqbyl.eval.benchmarks_io.append_to_dev_set`) and marks it accepted. The queue is
6
+ ``.sqbyl/`` scratch state — regenerable, gitignored — never a source of truth for the
7
+ golden set itself.
8
+
9
+ This module is deliberately **dev-safe**: it reads and writes only the candidate queue and
10
+ never touches ``test.yaml`` (invariant 3).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ from sqbyl.models import Candidate
18
+ from sqbyl.project import Project
19
+ from sqbyl.yamlio import dump_yaml, load_yaml
20
+ from sqbyl_runtime.state.layout import SqbylPaths
21
+
22
+
23
+ def candidates_path(project: Project) -> Path:
24
+ return SqbylPaths(project.root).root / "candidates.yaml"
25
+
26
+
27
+ def load_candidates(project: Project) -> list[Candidate]:
28
+ path = candidates_path(project)
29
+ if not path.exists():
30
+ return []
31
+ raw = load_yaml(path.read_text()) or []
32
+ return [Candidate.model_validate(item) for item in raw]
33
+
34
+
35
+ def save_candidates(project: Project, candidates: list[Candidate]) -> Path:
36
+ """Overwrite the queue with ``candidates`` (JSON-native scalars for portability)."""
37
+ path = candidates_path(project)
38
+ path.parent.mkdir(parents=True, exist_ok=True)
39
+ data = [c.model_dump(mode="json") for c in candidates]
40
+ path.write_text(dump_yaml(data))
41
+ return path
42
+
43
+
44
+ def add_candidates(project: Project, new: list[Candidate]) -> list[Candidate]:
45
+ """Append newly synthesized candidates to the queue, replacing any with the same id.
46
+
47
+ Returns the full queue after the merge. New candidates win on an id collision so a
48
+ re-synth refreshes stale pending items rather than duplicating them.
49
+ """
50
+ by_id = {c.id: c for c in load_candidates(project)}
51
+ for candidate in new:
52
+ by_id[candidate.id] = candidate
53
+ merged = list(by_id.values())
54
+ save_candidates(project, merged)
55
+ return merged
56
+
57
+
58
+ def get_candidate(project: Project, candidate_id: str) -> Candidate | None:
59
+ return next((c for c in load_candidates(project) if c.id == candidate_id), None)
60
+
61
+
62
+ def update_candidate(project: Project, candidate: Candidate) -> None:
63
+ """Persist an updated candidate (matched by id) back into the queue."""
64
+ candidates = load_candidates(project)
65
+ replaced = [candidate if c.id == candidate.id else c for c in candidates]
66
+ save_candidates(project, replaced)