logitgate 0.2.0__py3-none-any.whl

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.
logitgate/cli.py ADDED
@@ -0,0 +1,264 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from collections.abc import Sequence
6
+
7
+ from logitgate.cascade import Cascade
8
+ from logitgate.gates.ast_gate import ASTGate
9
+ from logitgate.gates.logit_gate import LogitGate
10
+
11
+
12
+ def create_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(
14
+ prog="logitgate",
15
+ description="LogitGate CLI: Calibrated first-token logit evaluation and cascaded routing.",
16
+ )
17
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
18
+
19
+ eval_parser = subparsers.add_parser(
20
+ "eval",
21
+ help="Evaluate candidate artifact against a rubric",
22
+ )
23
+ eval_parser.add_argument(
24
+ "--rubric",
25
+ required=True,
26
+ help="Evaluation rubric or question to verify against candidate",
27
+ )
28
+ eval_parser.add_argument(
29
+ "--candidate",
30
+ type=str,
31
+ default=None,
32
+ help="Candidate text/artifact to evaluate",
33
+ )
34
+ eval_parser.add_argument(
35
+ "--file",
36
+ type=str,
37
+ default=None,
38
+ help="Path to file containing candidate text to evaluate",
39
+ )
40
+ eval_parser.add_argument(
41
+ "--backend",
42
+ type=str,
43
+ default=None,
44
+ help="Backend specifier (defaults to LOGITGATE_BACKEND or stub://default)",
45
+ )
46
+ eval_parser.add_argument(
47
+ "--model",
48
+ type=str,
49
+ default=None,
50
+ help="Optional model name for backend",
51
+ )
52
+ eval_parser.add_argument(
53
+ "--threshold",
54
+ type=float,
55
+ default=None,
56
+ help="Pass probability threshold (defaults to LOGITGATE_PASS_THRESHOLD or 0.85)",
57
+ )
58
+ eval_parser.add_argument(
59
+ "--fail-threshold",
60
+ type=float,
61
+ default=None,
62
+ help="Fail probability threshold (defaults to LOGITGATE_FAIL_THRESHOLD or 0.20)",
63
+ )
64
+ eval_parser.add_argument(
65
+ "--ast-syntax",
66
+ action="store_true",
67
+ help="Enable Layer 0 AST syntax validation before logit evaluation",
68
+ )
69
+ eval_parser.add_argument(
70
+ "--json",
71
+ action="store_true",
72
+ help="Output verdict and traces in JSON format",
73
+ )
74
+
75
+ cal_parser = subparsers.add_parser(
76
+ "calibrate",
77
+ help="Run micro-calibration dataset to fit and cache optimal logit temperature",
78
+ )
79
+ cal_parser.add_argument(
80
+ "--backend",
81
+ type=str,
82
+ default=None,
83
+ help="Backend specifier (defaults to LOGITGATE_BACKEND or stub://default)",
84
+ )
85
+ cal_parser.add_argument(
86
+ "--model",
87
+ type=str,
88
+ default=None,
89
+ help="Optional model name for backend",
90
+ )
91
+ cal_parser.add_argument(
92
+ "--cache-path",
93
+ type=str,
94
+ default=None,
95
+ help="Optional custom path to calibration cache JSON file",
96
+ )
97
+
98
+ explain_parser = subparsers.add_parser(
99
+ "explain",
100
+ help="Replay frozen failure snapshot and generate full Layer-2 diagnostic explanation",
101
+ )
102
+ explain_parser.add_argument(
103
+ "snapshot_path",
104
+ nargs="?",
105
+ default=".logitgate/failures",
106
+ help="Path to snapshot JSON file or directory containing latest.json (default: .logitgate/failures)",
107
+ )
108
+ explain_parser.add_argument(
109
+ "--backend",
110
+ type=str,
111
+ default=None,
112
+ help="Generative backend specifier (e.g. stub://..., ollama://...)",
113
+ )
114
+ explain_parser.add_argument(
115
+ "--model",
116
+ type=str,
117
+ default=None,
118
+ help="Optional model name for backend",
119
+ )
120
+ explain_parser.add_argument(
121
+ "--max-tokens",
122
+ type=int,
123
+ default=None,
124
+ help="Maximum tokens for generative explanation",
125
+ )
126
+ explain_parser.add_argument(
127
+ "--no-think",
128
+ action="store_true",
129
+ help="Disable Chain-of-Thought reasoning tokens in generative explanation",
130
+ )
131
+ explain_parser.add_argument(
132
+ "--json",
133
+ action="store_true",
134
+ help="Output enriched verdict and traces in JSON format",
135
+ )
136
+
137
+ return parser
138
+
139
+
140
+ def main(argv: Sequence[str] | None = None) -> int:
141
+ parser = create_parser()
142
+ args = parser.parse_args(argv)
143
+
144
+ if args.command == "calibrate":
145
+ from pathlib import Path
146
+
147
+ from logitgate.backends.base import resolve_logit_backend
148
+ from logitgate.debiasing.permutation import auto_calibrate_backend
149
+
150
+ backend = resolve_logit_backend(args.backend, model=args.model)
151
+ cache_path = Path(args.cache_path) if args.cache_path else None
152
+ effective_model = args.model or getattr(backend, "model", "default")
153
+
154
+ print(f"Calibrating model '{effective_model}' on backend {backend.__class__.__name__}...")
155
+ result = auto_calibrate_backend(
156
+ backend,
157
+ model_key=effective_model,
158
+ save_cache=True,
159
+ cache_path=cache_path,
160
+ )
161
+ print(f"Optimal Temperature: {result.optimal_temperature:.4f}")
162
+ print(f"Brier Score: {result.brier_score_before:.6f} -> {result.brier_score_after:.6f}")
163
+ print(f"ECE: {result.ece_before:.6f} -> {result.ece_after:.6f}")
164
+ print("Calibration successfully saved to cache.")
165
+ return 0
166
+
167
+ if args.command == "explain":
168
+ from logitgate.snapshot import VerdictSnapshot
169
+ try:
170
+ snapshot = VerdictSnapshot.load(args.snapshot_path)
171
+ except (OSError, ValueError, KeyError) as e:
172
+ sys.stderr.write(f"Error loading snapshot from '{args.snapshot_path}': {e}\n")
173
+ return 1
174
+
175
+ think = not args.no_think
176
+ verdict = snapshot.explain(
177
+ backend=args.backend,
178
+ model=args.model,
179
+ max_tokens=args.max_tokens,
180
+ think=think,
181
+ )
182
+
183
+ if args.json:
184
+ print(verdict.model_dump_json(indent=2))
185
+ else:
186
+ print("=" * 60)
187
+ print("LOGITGATE FAILURE REPLAY & EXPLANATION")
188
+ print("=" * 60)
189
+ print(f"Snapshot ID: {snapshot.snapshot_id}")
190
+ if snapshot.test_name:
191
+ print(f"Test Name: {snapshot.test_name}")
192
+ print(f"Timestamp: {snapshot.timestamp}")
193
+ print(f"Rubric: {snapshot.rubric}")
194
+ orig_prob = snapshot.verdict.probability
195
+ prob_str = f"{orig_prob:.4f}" if orig_prob is not None else "N/A"
196
+ print(f"Original Prob: {prob_str}")
197
+ print(f"Triggered Layer: {snapshot.verdict.triggered_layer}")
198
+ print("-" * 60)
199
+ print(f"Replay Verdict: {verdict.status.value.upper()}")
200
+ print(f"Replay Latency: {verdict.total_latency_ms:.2f}ms")
201
+ print(f"Tokens Spent: {verdict.total_tokens_generated}")
202
+ print("-" * 60)
203
+ print("Diagnostic Critique:")
204
+ print(verdict.reason or "(No reason provided)")
205
+ print("=" * 60)
206
+ return 0
207
+
208
+ if args.command != "eval":
209
+ parser.print_help()
210
+ return 1
211
+
212
+ candidate: str | None = None
213
+ if args.file:
214
+ try:
215
+ with open(args.file, "r", encoding="utf-8") as f:
216
+ candidate = f.read()
217
+ except OSError as e:
218
+ sys.stderr.write(f"Error reading file '{args.file}': {e}\n")
219
+ return 1
220
+ elif args.candidate is not None:
221
+ candidate = args.candidate
222
+ else:
223
+ if not sys.stdin.isatty():
224
+ candidate = sys.stdin.read()
225
+ else:
226
+ sys.stderr.write("Error: Either --candidate or --file must be specified.\n")
227
+ return 1
228
+
229
+ logit_gate = LogitGate(
230
+ rubric=args.rubric,
231
+ backend=args.backend,
232
+ model=args.model,
233
+ pass_threshold=args.threshold,
234
+ fail_threshold=args.fail_threshold,
235
+ )
236
+
237
+ evaluator: Cascade | LogitGate
238
+ if args.ast_syntax:
239
+ evaluator = Cascade([
240
+ ASTGate(syntax=True),
241
+ logit_gate,
242
+ ])
243
+ else:
244
+ evaluator = logit_gate
245
+
246
+ verdict = evaluator.evaluate(candidate)
247
+
248
+ if args.json:
249
+ print(verdict.model_dump_json(indent=2))
250
+ else:
251
+ verdict_str = "PASSED" if verdict.passed else "FAILED"
252
+ print(f"Verdict: {verdict_str} (status: {verdict.status.value})")
253
+ if verdict.probability is not None:
254
+ print(f"Confidence: {verdict.probability:.4f}")
255
+ print(f"Triggered Layer: {verdict.triggered_layer}")
256
+ print(f"Total Latency: {verdict.total_latency_ms:.2f}ms")
257
+ if verdict.reason:
258
+ print(f"Reason: {verdict.reason}")
259
+
260
+ return 0 if verdict.passed else 1
261
+
262
+
263
+ if __name__ == "__main__":
264
+ sys.exit(main())
logitgate/config.py ADDED
@@ -0,0 +1,172 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+
6
+ class _ConfigState:
7
+ def __init__(self) -> None:
8
+ self.backend: str | None = None
9
+ self.model: str | None = None
10
+ self.pass_threshold: float | None = None
11
+ self.fail_threshold: float | None = None
12
+ self.logit_temperature: float | None = None
13
+ self.num_ctx: int | None = None
14
+ self.save_failures: bool | None = None
15
+ self.snapshot_dir: str | None = None
16
+
17
+ def reset(self) -> None:
18
+ self.backend = None
19
+ self.model = None
20
+ self.pass_threshold = None
21
+ self.fail_threshold = None
22
+ self.logit_temperature = None
23
+ self.num_ctx = None
24
+ self.save_failures = None
25
+ self.snapshot_dir = None
26
+
27
+
28
+ _state = _ConfigState()
29
+
30
+
31
+ class _DefaultsMeta(type):
32
+ @property
33
+ def backend(cls) -> str:
34
+ if _state.backend is not None:
35
+ return _state.backend
36
+ return os.environ.get("LOGITGATE_BACKEND", "stub://default")
37
+
38
+ @backend.setter
39
+ def backend(cls, val: str | None) -> None:
40
+ _state.backend = val
41
+
42
+ @property
43
+ def model(cls) -> str | None:
44
+ if _state.model is not None:
45
+ return _state.model
46
+ return os.environ.get("LOGITGATE_MODEL", None)
47
+
48
+ @model.setter
49
+ def model(cls, val: str | None) -> None:
50
+ _state.model = val
51
+
52
+ @property
53
+ def pass_threshold(cls) -> float:
54
+ if _state.pass_threshold is not None:
55
+ return _state.pass_threshold
56
+ val = os.environ.get("LOGITGATE_PASS_THRESHOLD")
57
+ return float(val) if val is not None else 0.85
58
+
59
+ @pass_threshold.setter
60
+ def pass_threshold(cls, val: float | None) -> None:
61
+ _state.pass_threshold = val
62
+
63
+ @property
64
+ def fail_threshold(cls) -> float:
65
+ if _state.fail_threshold is not None:
66
+ return _state.fail_threshold
67
+ val = os.environ.get("LOGITGATE_FAIL_THRESHOLD")
68
+ return float(val) if val is not None else 0.20
69
+
70
+ @fail_threshold.setter
71
+ def fail_threshold(cls, val: float | None) -> None:
72
+ _state.fail_threshold = val
73
+
74
+ @property
75
+ def logit_temperature(cls) -> float:
76
+ if _state.logit_temperature is not None:
77
+ return _state.logit_temperature
78
+ val = os.environ.get("LOGITGATE_TEMPERATURE")
79
+ return float(val) if val is not None else 1.0
80
+
81
+ @logit_temperature.setter
82
+ def logit_temperature(cls, val: float | None) -> None:
83
+ _state.logit_temperature = val
84
+
85
+ @property
86
+ def num_ctx(cls) -> int | None:
87
+ if _state.num_ctx is not None:
88
+ return _state.num_ctx
89
+ val = os.environ.get("LOGITGATE_NUM_CTX")
90
+ return int(val) if val is not None else None
91
+
92
+ @num_ctx.setter
93
+ def num_ctx(cls, val: int | None) -> None:
94
+ _state.num_ctx = val
95
+
96
+ @property
97
+ def save_failures(cls) -> bool:
98
+ if _state.save_failures is not None:
99
+ return _state.save_failures
100
+ return os.environ.get("LOGITGATE_SAVE_FAILURES", "0").lower() in ("1", "true", "yes")
101
+
102
+ @save_failures.setter
103
+ def save_failures(cls, val: bool | None) -> None:
104
+ _state.save_failures = val
105
+
106
+ @property
107
+ def snapshot_dir(cls) -> str:
108
+ if _state.snapshot_dir is not None:
109
+ return _state.snapshot_dir
110
+ return os.environ.get("LOGITGATE_SNAPSHOT_DIR", ".logitgate/failures")
111
+
112
+ @snapshot_dir.setter
113
+ def snapshot_dir(cls, val: str | None) -> None:
114
+ _state.snapshot_dir = val
115
+
116
+
117
+ class LogitGateDefaults(metaclass=_DefaultsMeta):
118
+ """Global configuration defaults for LogitGate."""
119
+
120
+
121
+ def configure(
122
+ backend: str | None = None,
123
+ model: str | None = None,
124
+ pass_threshold: float | None = None,
125
+ fail_threshold: float | None = None,
126
+ logit_temperature: float | None = None,
127
+ num_ctx: int | None = None,
128
+ save_failures: bool | None = None,
129
+ snapshot_dir: str | None = None,
130
+ ) -> None:
131
+ """Configures global defaults for LogitGate."""
132
+ if backend is not None:
133
+ _state.backend = backend
134
+ if model is not None:
135
+ _state.model = model
136
+ if pass_threshold is not None:
137
+ _state.pass_threshold = pass_threshold
138
+ if fail_threshold is not None:
139
+ _state.fail_threshold = fail_threshold
140
+ if logit_temperature is not None:
141
+ _state.logit_temperature = logit_temperature
142
+ if num_ctx is not None:
143
+ _state.num_ctx = num_ctx
144
+ if save_failures is not None:
145
+ _state.save_failures = save_failures
146
+ if snapshot_dir is not None:
147
+ _state.snapshot_dir = snapshot_dir
148
+
149
+
150
+ def reset_config() -> None:
151
+ """Resets global configuration to environment variables and defaults."""
152
+ _state.reset()
153
+
154
+
155
+ def get_default_backend() -> str:
156
+ """Resolves default backend string via configure() or LOGITGATE_BACKEND."""
157
+ return LogitGateDefaults.backend
158
+
159
+
160
+ def get_default_model() -> str | None:
161
+ """Resolves default model string via configure() or LOGITGATE_MODEL."""
162
+ return LogitGateDefaults.model
163
+
164
+
165
+ def get_default_save_failures() -> bool:
166
+ """Resolves default failure saving flag via configure() or LOGITGATE_SAVE_FAILURES."""
167
+ return LogitGateDefaults.save_failures
168
+
169
+
170
+ def get_default_snapshot_dir() -> str:
171
+ """Resolves default snapshot directory via configure() or LOGITGATE_SNAPSHOT_DIR."""
172
+ return LogitGateDefaults.snapshot_dir
@@ -0,0 +1,35 @@
1
+ from logitgate.debiasing.inversion import (
2
+ compile_batch_binary_prompt,
3
+ compile_binary_prompt,
4
+ compile_choice_prompt,
5
+ compile_score_prompt,
6
+ )
7
+ from logitgate.debiasing.permutation import (
8
+ KNOWN_MODEL_TEMPERATURES,
9
+ CalibrationResult,
10
+ auto_calibrate_backend,
11
+ combine_symmetric_log_odds,
12
+ fit_calibration,
13
+ get_model_temperature,
14
+ )
15
+ from logitgate.debiasing.softmax import (
16
+ compute_binary_log_odds,
17
+ compute_normalized_probabilities,
18
+ extract_token_logprob,
19
+ )
20
+
21
+ __all__ = [
22
+ "KNOWN_MODEL_TEMPERATURES",
23
+ "CalibrationResult",
24
+ "auto_calibrate_backend",
25
+ "combine_symmetric_log_odds",
26
+ "compile_batch_binary_prompt",
27
+ "compile_binary_prompt",
28
+ "compile_choice_prompt",
29
+ "compile_score_prompt",
30
+ "compute_binary_log_odds",
31
+ "compute_normalized_probabilities",
32
+ "extract_token_logprob",
33
+ "fit_calibration",
34
+ "get_model_temperature",
35
+ ]
@@ -0,0 +1,180 @@
1
+ from __future__ import annotations
2
+
3
+ from collections.abc import Mapping, Sequence
4
+ from typing import Any
5
+
6
+
7
+ def compile_binary_prompt(
8
+ rubric: str,
9
+ candidate: Any,
10
+ pass_token: str = "A",
11
+ fail_token: str = "B",
12
+ query_first: bool = True,
13
+ pass_description: str = "The candidate artifact complies fully with the rubric.",
14
+ fail_description: str = "The candidate artifact violates the rubric or has defects.",
15
+ ) -> str:
16
+ """
17
+ Compiles a prefix-cache-friendly binary evaluation prompt.
18
+ When query_first=True, the rubric and candidate artifact form a 100% byte-identical
19
+ static prefix across both Pass 1 and Pass 2, allowing KV-cache hit rates > 99%.
20
+ """
21
+ cand_str = str(candidate).strip()
22
+ rubric_str = rubric.strip()
23
+
24
+ if query_first:
25
+ prefix = f"[EVALUATION RUBRIC]\n{rubric_str}\n\n[CANDIDATE ARTIFACT]\n{cand_str}\n\n"
26
+ else:
27
+ prefix = f"[CANDIDATE ARTIFACT]\n{cand_str}\n\n[EVALUATION RUBRIC]\n{rubric_str}\n\n"
28
+
29
+ t_pass = pass_token.strip().upper()
30
+ t_fail = fail_token.strip().upper()
31
+
32
+ mapping = {
33
+ t_pass: pass_description,
34
+ t_fail: fail_description,
35
+ }
36
+ sorted_tokens = sorted(mapping.keys())
37
+ option_lines = "\n".join(f"{tok}: {mapping[tok]}" for tok in sorted_tokens)
38
+ tokens_str = f"{sorted_tokens[0]} or {sorted_tokens[1]}" if len(sorted_tokens) == 2 else " or ".join(sorted_tokens)
39
+
40
+ suffix = (
41
+ f"[DECISION MAPPING]\n"
42
+ f"{option_lines}\n"
43
+ f"Respond with a single character ({tokens_str}).\n"
44
+ f"Verdict:"
45
+ )
46
+ return prefix + suffix
47
+
48
+
49
+ def compile_batch_binary_prompt(
50
+ rubrics: Sequence[str],
51
+ candidate: Any,
52
+ rubric_index: int,
53
+ pass_token: str = "A",
54
+ fail_token: str = "B",
55
+ query_first: bool = False,
56
+ pass_description: str = "The candidate artifact complies fully with the rubric.",
57
+ fail_description: str = "The candidate artifact violates the rubric or has defects.",
58
+ ) -> str:
59
+ """
60
+ Compiles an isolated, prefix-cache-friendly binary evaluation prompt for an individual rubric
61
+ within a multi-rubric evaluation set.
62
+
63
+ Architecture:
64
+ The candidate artifact forms the shared static prefix:
65
+ [CANDIDATE ARTIFACT]
66
+ {candidate}
67
+ This prefix is 100% byte-identical across ALL K rubrics and across both forward (Pass 1) and
68
+ reverse (Pass 2) passes, allowing the inference server's KV-cache to retain the candidate tokens
69
+ across all queries.
70
+
71
+ Crucially, each rubric is evaluated in complete isolation: no other rubrics are loaded into the
72
+ prompt context. This prevents cross-rubric attention leakage and context bloat, scaling
73
+ consistently whether evaluating 2 rubrics or 500 rubrics.
74
+ """
75
+ if rubric_index < 0 or rubric_index >= len(rubrics):
76
+ raise IndexError(f"rubric_index {rubric_index} out of bounds for rubrics list of size {len(rubrics)}")
77
+
78
+ cand_str = str(candidate).strip()
79
+ target_rubric = rubrics[rubric_index].strip()
80
+
81
+ t_pass = pass_token.strip().upper()
82
+ t_fail = fail_token.strip().upper()
83
+
84
+ mapping = {
85
+ t_pass: pass_description,
86
+ t_fail: fail_description,
87
+ }
88
+ sorted_tokens = sorted(mapping.keys())
89
+ option_lines = "\n".join(f"{tok}: {mapping[tok]}" for tok in sorted_tokens)
90
+ tokens_str = f"{sorted_tokens[0]} or {sorted_tokens[1]}" if len(sorted_tokens) == 2 else " or ".join(sorted_tokens)
91
+
92
+ if not query_first:
93
+ prefix = f"[CANDIDATE ARTIFACT]\n{cand_str}\n\n"
94
+ suffix = (
95
+ f"[EVALUATION RUBRIC]\n"
96
+ f"{target_rubric}\n\n"
97
+ f"[DECISION MAPPING]\n"
98
+ f"{option_lines}\n"
99
+ f"Respond with a single character ({tokens_str}).\n"
100
+ f"Verdict:"
101
+ )
102
+ else:
103
+ prefix = f"[EVALUATION RUBRIC]\n{target_rubric}\n\n[CANDIDATE ARTIFACT]\n{cand_str}\n\n"
104
+ suffix = (
105
+ f"[DECISION MAPPING]\n"
106
+ f"{option_lines}\n"
107
+ f"Respond with a single character ({tokens_str}).\n"
108
+ f"Verdict:"
109
+ )
110
+
111
+ return prefix + suffix
112
+
113
+
114
+ def compile_choice_prompt(
115
+ question: str,
116
+ candidate: Any,
117
+ options: Mapping[str, str],
118
+ query_first: bool = True,
119
+ ) -> str:
120
+ """
121
+ Compiles a discrete multi-choice prompt with identical static prefix.
122
+ """
123
+ cand_str = str(candidate).strip()
124
+ q_str = question.strip()
125
+
126
+ if query_first:
127
+ prefix = f"[QUESTION]\n{q_str}\n\n[CANDIDATE ARTIFACT]\n{cand_str}\n\n"
128
+ else:
129
+ prefix = f"[CANDIDATE ARTIFACT]\n{cand_str}\n\n[QUESTION]\n{q_str}\n\n"
130
+
131
+ option_lines = "\n".join(f"{k}: {v}" for k, v in options.items())
132
+ keys = list(options.keys())
133
+ keys_str = " or ".join(keys)
134
+
135
+ suffix = (
136
+ f"[DECISION MAPPING]\n"
137
+ f"{option_lines}\n"
138
+ f"Respond with a single character ({keys_str}).\n"
139
+ f"Choice:"
140
+ )
141
+ return prefix + suffix
142
+
143
+
144
+ def compile_score_prompt(
145
+ rubric: str,
146
+ candidate: Any,
147
+ min_score: int = 1,
148
+ max_score: int = 5,
149
+ query_first: bool = True,
150
+ inverted: bool = False,
151
+ ) -> str:
152
+ """
153
+ Compiles a bounded integer scoring prompt with identical static prefix.
154
+ When inverted=True, min_score corresponds to highest quality and max_score
155
+ corresponds to lowest quality, enabling symmetric score debiasing with
156
+ byte-identical prefix caching.
157
+ """
158
+ cand_str = str(candidate).strip()
159
+ rubric_str = rubric.strip()
160
+
161
+ if query_first:
162
+ prefix = f"[EVALUATION RUBRIC]\n{rubric_str}\n\n[CANDIDATE ARTIFACT]\n{cand_str}\n\n"
163
+ else:
164
+ prefix = f"[CANDIDATE ARTIFACT]\n{cand_str}\n\n[EVALUATION RUBRIC]\n{rubric_str}\n\n"
165
+
166
+ if inverted:
167
+ scale_desc = (
168
+ f"Rate the candidate artifact on an inverted scale where {min_score} is highest quality "
169
+ f"and {max_score} is lowest quality based on the rubric.\n"
170
+ )
171
+ else:
172
+ scale_desc = f"Rate the candidate artifact from {min_score} to {max_score} based on the rubric.\n"
173
+
174
+ suffix = (
175
+ f"[SCORING SCALE]\n"
176
+ f"{scale_desc}"
177
+ f"Respond with a single integer ({min_score}-{max_score}).\n"
178
+ f"Score:"
179
+ )
180
+ return prefix + suffix