fluid-decisions 0.1.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.
fluid/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Fluid Decisions: lightweight typed decisions with policy outside the model."""
2
+
3
+ __version__ = "0.1.0"
fluid/audit.py ADDED
@@ -0,0 +1,45 @@
1
+ """Append-only decision records; state bodies are never persisted."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import os
7
+ from datetime import datetime, timezone
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+
12
+ def data_directory() -> Path:
13
+ return Path(os.environ.get('FLUID_DATA_DIR', '~/.fluid-decisions')).expanduser()
14
+
15
+
16
+ def append_record(path: Path, record: dict[str, Any]) -> None:
17
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
18
+ raw = (json.dumps(record, allow_nan=False, separators=(',', ':')) + '\n').encode()
19
+ fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
20
+ try:
21
+ # A single append avoids interleaving between concurrent short records.
22
+ if os.write(fd, raw) != len(raw):
23
+ raise OSError('incomplete audit append')
24
+ finally:
25
+ os.close(fd)
26
+
27
+
28
+ class Audit:
29
+ def __init__(self, path: Path | None = None):
30
+ self.path = path or data_directory() / 'audit.jsonl'
31
+
32
+ def record(self, decision: dict[str, Any], *, state: Any = None) -> None:
33
+ fields = ('job', 'lane', 'pack_version', 'verdict', 'answers', 'timings',
34
+ 'usage', 'reasons', 'passes', 'flagged', 'complete', 'taint_cleared',
35
+ 'clearance_reason', 'session_id_sha256', 'static_injection_rule',
36
+ 'windows', 'first_flagged_window', 'window_answers', 'session_assessment',
37
+ 'check', 'checked', 'budget_source', 'expected_ms', 'resolved', 'scoping',
38
+ 'budget')
39
+ record = {key: decision[key] for key in fields if key in decision}
40
+ record['timestamp'] = datetime.now(timezone.utc).isoformat()
41
+ if os.environ.get('FLUID_AUDIT_STATE') == '1':
42
+ raw = json.dumps(state, sort_keys=True, ensure_ascii=False).encode()
43
+ record['state'] = {'sha256': hashlib.sha256(raw).hexdigest(),
44
+ 'preview': '[state content redacted]', 'bytes': len(raw)}
45
+ append_record(self.path, record)
fluid/cli.py ADDED
@@ -0,0 +1,151 @@
1
+ """Command line surfaces for Fluid Decisions.
2
+
3
+ The CLI deliberately has a small JSON contract: input and output are JSON, while
4
+ the exit status communicates the decision class to shell hooks.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import argparse
9
+ import base64
10
+ import json
11
+ import mimetypes
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from .service import Decisions
17
+
18
+
19
+ def _service() -> Any:
20
+ return Decisions()
21
+
22
+
23
+ def _json(value: Any) -> None:
24
+ print(json.dumps(value, ensure_ascii=False, separators=(",", ":")))
25
+
26
+
27
+ def _read_json(value: str | None, filename: str | None, *, stdin: Any = None) -> Any:
28
+ if value is not None and filename is not None:
29
+ raise ValueError("use only one of --state and --file")
30
+ if filename:
31
+ text = Path(filename).read_text(encoding="utf-8")
32
+ elif value is not None:
33
+ text = value
34
+ else:
35
+ text = (sys.stdin if stdin is None else stdin).read()
36
+ if not text.strip():
37
+ raise ValueError("JSON input is required")
38
+ return json.loads(text)
39
+
40
+
41
+ def _media(paths: list[str], kind: str) -> list[str]:
42
+ result = []
43
+ for filename in paths:
44
+ content = Path(filename).read_bytes()
45
+ mime = mimetypes.guess_type(filename)[0] or ("image/png" if kind == "image" else "audio/wav")
46
+ result.append(f"data:{mime};base64,{base64.b64encode(content).decode('ascii')}")
47
+ return result
48
+
49
+
50
+ def _result_exit(result: Any, command: str) -> int:
51
+ if not isinstance(result, dict):
52
+ return 2
53
+ if result.get("complete") is False:
54
+ return 2
55
+ verdict = result.get("verdict")
56
+ if command == "verify":
57
+ findings = result.get("findings", result.get("claims", [])) or []
58
+ if verdict in {"deny", "high"} or any(
59
+ isinstance(item, dict) and item.get("verdict") in {"deny", "high", "flagged"}
60
+ for item in findings
61
+ ):
62
+ return 1
63
+ if verdict in {"deny", "high"}:
64
+ return 1
65
+ if verdict in {"ask", "incomplete"}:
66
+ return 2
67
+ if result.get("error") or result.get("incomplete"):
68
+ return 2
69
+ if command == "health" and any(
70
+ isinstance(result.get(lane), dict) and result[lane].get("error") for lane in ("max",)
71
+ ):
72
+ return 2
73
+ return 0
74
+
75
+
76
+ def _parser() -> argparse.ArgumentParser:
77
+ parser = argparse.ArgumentParser(prog="fluid")
78
+ sub = parser.add_subparsers(dest="command", required=True)
79
+ for name in ("classify", "route"):
80
+ command = sub.add_parser(name)
81
+ command.add_argument("--state")
82
+ command.add_argument("--file")
83
+ command.add_argument("--destinations", help="JSON object for route")
84
+ command.add_argument("--image", action="append", default=[])
85
+ command.add_argument("--audio")
86
+ gate = sub.add_parser("gate")
87
+ gate.add_argument("--tool", required=True)
88
+ gate.add_argument("--input", required=True, help="tool input JSON")
89
+ gate.add_argument("--user-request", default="")
90
+ gate.add_argument("--session")
91
+ verify = sub.add_parser("verify")
92
+ verify.add_argument("--source")
93
+ verify.add_argument("--source-file")
94
+ verify.add_argument("--claim", action="append", required=True)
95
+ session = sub.add_parser("session")
96
+ session_sub = session.add_subparsers(dest="session_command", required=True)
97
+ clear = session_sub.add_parser("clear")
98
+ clear.add_argument("--session", required=True)
99
+ clear.add_argument("--reason", default="operator clearance")
100
+ recheck = session_sub.add_parser("recheck")
101
+ recheck.add_argument("--session", required=True)
102
+ recheck.add_argument("--file", help="Original result text; stdin if omitted")
103
+ sub.add_parser("health")
104
+ return parser
105
+
106
+
107
+ def main(argv: list[str] | None = None) -> int:
108
+ parser = _parser()
109
+ try:
110
+ args = parser.parse_args(argv)
111
+ service = _service()
112
+ try:
113
+ if args.command in {"classify", "route"}:
114
+ state = _read_json(args.state, args.file)
115
+ images = _media(args.image, "image") if args.image else None
116
+ audio = _media([args.audio], "audio")[0] if args.audio else None
117
+ if args.command == "classify":
118
+ result = service.classify(state, images=images, audio=audio)
119
+ else:
120
+ destinations = json.loads(args.destinations) if args.destinations else None
121
+ result = service.route(state, destinations=destinations, images=images, audio=audio)
122
+ elif args.command == "gate":
123
+ result = service.gate(args.tool, json.loads(args.input), user_request=args.user_request, session_id=args.session)
124
+ elif args.command == "verify":
125
+ source = args.source or (Path(args.source_file).read_text(encoding="utf-8") if args.source_file else None)
126
+ if source is None:
127
+ raise ValueError("--source or --source-file is required")
128
+ result = service.verify(source, args.claim)
129
+ elif args.command == "session" and args.session_command == "clear":
130
+ result = service.clear_session(args.session, reason=args.reason)
131
+ elif args.command == "session" and args.session_command == "recheck":
132
+ if args.file:
133
+ text = Path(args.file).read_bytes().decode('utf-8')
134
+ elif hasattr(sys.stdin, 'buffer'):
135
+ text = sys.stdin.buffer.read().decode('utf-8')
136
+ else:
137
+ text = sys.stdin.read()
138
+ result = service.post_result(text, session_id=args.session, recheck=True)
139
+ else:
140
+ result = service.health()
141
+ finally:
142
+ service.close()
143
+ _json(result)
144
+ return _result_exit(result, args.command)
145
+ except Exception as exc:
146
+ _json({"verdict": "ask", "error": {"reason": "invalid_input", "message": str(exc)}})
147
+ return 2
148
+
149
+
150
+ if __name__ == "__main__": # pragma: no cover
151
+ raise SystemExit(main())
fluid/client.py ADDED
@@ -0,0 +1,376 @@
1
+ """Small HTTP client for the d1 System One decision endpoint."""
2
+ from __future__ import annotations
3
+
4
+ import copy
5
+ import math
6
+ import os
7
+ import time
8
+ from pathlib import Path
9
+ from typing import Any, cast
10
+ from urllib.parse import urlparse
11
+
12
+ import httpx
13
+ import yaml
14
+
15
+
16
+ class ClientError(Exception):
17
+ def __init__(self, reason: str, message: str = "client error"):
18
+ self.reason = reason
19
+ super().__init__(message)
20
+
21
+
22
+ def _probabilities(value: Any, label: str) -> None:
23
+ if not isinstance(value, dict) or not value:
24
+ raise ClientError("invalid_response", f"invalid response: {label} probability distribution")
25
+ numbers = list(value.values())
26
+ if any(not isinstance(number, (int, float)) or isinstance(number, bool) or not math.isfinite(number) or not 0 <= number <= 1 for number in numbers):
27
+ raise ClientError("invalid_response", f"invalid response: {label} probability distribution")
28
+ if not math.isclose(sum(numbers), 1.0, abs_tol=1e-4):
29
+ raise ClientError("invalid_response", f"invalid response: {label} probability distribution")
30
+
31
+
32
+ def _confidence(answer: dict[str, Any], label: str) -> None:
33
+ value = answer.get("confidence")
34
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value) or not 0 <= value <= 1:
35
+ raise ClientError("invalid_response", f"invalid response: confidence for {label}")
36
+
37
+
38
+ def validate_response(response: Any, questions: dict[str, dict[str, Any]]) -> None:
39
+ if not isinstance(response, dict):
40
+ raise ClientError("invalid_response", "invalid response: expected object")
41
+ answers = response.get("answers")
42
+ usage = response.get("usage")
43
+ if not isinstance(answers, dict) or not isinstance(usage, dict) or usage.get("output_tokens") != 0:
44
+ raise ClientError("invalid_response", "invalid response: answers or usage")
45
+ if set(answers) != set(questions):
46
+ raise ClientError("invalid_response", "invalid response: answer set does not match questions")
47
+ for name, question in questions.items():
48
+ answer = answers.get(name)
49
+ expected = question.get("type")
50
+ if expected not in {"noul", "choice", "score"}:
51
+ raise ClientError("invalid_response", f"invalid response: unsupported question type for {name}")
52
+ if not isinstance(answer, dict) or answer.get("type") != expected:
53
+ raise ClientError("invalid_response", f"invalid response: answer type for {name}")
54
+ if expected == "noul":
55
+ value = answer.get("noul")
56
+ if not isinstance(value, (int, float)) or isinstance(value, bool) or not math.isfinite(value) or not 0 <= value <= 1:
57
+ raise ClientError("invalid_response", f"invalid response: noul answer for {name}")
58
+ elif expected == "choice":
59
+ probabilities = answer.get("probabilities")
60
+ _probabilities(probabilities, name)
61
+ _confidence(answer, name)
62
+ criteria = question.get("criteria")
63
+ probability_map = cast(dict[str, Any], probabilities)
64
+ if not isinstance(criteria, dict) or set(probability_map) != set(criteria):
65
+ raise ClientError("invalid_response", f"invalid response: choice criteria for {name}")
66
+ if answer.get("choice") not in probability_map:
67
+ raise ClientError("invalid_response", f"invalid response: choice answer for {name}")
68
+ elif expected == "score":
69
+ probabilities = answer.get("probabilities")
70
+ _probabilities(probabilities, name)
71
+ probability_map = cast(dict[str, Any], probabilities)
72
+ _confidence(answer, name)
73
+ criteria = question.get("criteria")
74
+ criteria_list = cast(list[Any], criteria) if isinstance(criteria, list) else []
75
+ expected_keys = {str(index) for index in range(len(criteria_list))}
76
+ if not expected_keys or set(probability_map) != expected_keys:
77
+ raise ClientError("invalid_response", f"invalid response: score criteria for {name}")
78
+ if not isinstance(answer.get("score"), (int, float)) or isinstance(answer.get("score"), bool) or not math.isfinite(answer["score"]) or not 0 <= answer["score"] <= len(criteria_list) - 1:
79
+ raise ClientError("invalid_response", f"invalid response: score answer for {name}")
80
+ legend = answer.get("legend")
81
+ if not isinstance(legend, dict) or set(legend) != expected_keys or any(legend[str(index)] != criteria_list[index] for index in range(len(criteria_list))):
82
+ raise ClientError("invalid_response", f"invalid response: score legend for {name}")
83
+
84
+
85
+ def enrich_response(response: dict[str, Any], lane: str, client_ms: float, questions: dict[str, dict[str, Any]]) -> dict[str, Any]:
86
+ result = copy.deepcopy(response)
87
+ for name, answer in result["answers"].items():
88
+ if answer["type"] == "noul":
89
+ answer["decisiveness"] = 2 * abs(answer["noul"] - 0.5)
90
+ elif answer["type"] in {"choice", "score"}:
91
+ answer["top_probability"] = max(answer["probabilities"].values())
92
+ result["lane"] = lane
93
+ timings = result.get("timings")
94
+ result["timings"] = dict(timings) if isinstance(timings, dict) else {}
95
+ result["timings"]["client_ms"] = round(client_ms, 1)
96
+ return result
97
+
98
+
99
+ def _default_urls() -> dict[str, str]:
100
+ path = Path(__file__).with_name("lanes.yaml")
101
+ raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
102
+ configured = cast(dict[str, Any], raw.get("lanes", raw))
103
+ return {lane: str(cast(dict[str, Any], configured[lane])["url"]).rstrip("/") for lane in ("max", "omni") if isinstance(configured.get(lane), dict) and configured[lane].get("url")}
104
+
105
+
106
+ def _valid_url(value: str) -> bool:
107
+ parsed = urlparse(value)
108
+ return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
109
+
110
+
111
+ def _connection_reset(error: BaseException) -> bool:
112
+ seen: set[int] = set()
113
+ current: BaseException | None = error
114
+ while current is not None and id(current) not in seen:
115
+ seen.add(id(current))
116
+ if isinstance(current, OSError) and getattr(current, "errno", None) in {54, 104}:
117
+ return True
118
+ current = current.__cause__ or current.__context__
119
+ return False
120
+
121
+
122
+ class D1Client:
123
+ def __init__(self, lanes_file: str | os.PathLike[str] | None = None, transport: httpx.BaseTransport | None = None):
124
+ selected = lanes_file or os.environ.get("FLUID_LANES_FILE")
125
+ if selected:
126
+ self.urls = {}
127
+ path = Path(selected)
128
+ raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
129
+ configured = cast(dict[str, Any], raw.get("lanes", raw))
130
+ for lane in ("max", "omni"):
131
+ if isinstance(configured.get(lane), dict) and configured[lane].get("url"):
132
+ self.urls[lane] = str(configured[lane]["url"]).rstrip("/")
133
+ else:
134
+ self.urls = _default_urls()
135
+ for lane in ("max", "omni"):
136
+ override = os.environ.get("FD_MAX_URL" if lane == "max" else "FD_OMNI_URL")
137
+ if override is not None:
138
+ if not override.strip():
139
+ self.urls.pop(lane, None)
140
+ else:
141
+ self.urls[lane] = override.rstrip("/")
142
+ if any(not _valid_url(url) for url in self.urls.values()):
143
+ raise ValueError("lane URLs must be absolute HTTP(S) URLs")
144
+ self.first_timeout = float(os.environ.get("FD_FIRST_TIMEOUT", "8"))
145
+ self.timeout = float(os.environ.get("FD_TIMEOUT", "1"))
146
+ if not math.isfinite(self.first_timeout) or not math.isfinite(self.timeout) or self.first_timeout <= 0 or self.timeout <= 0:
147
+ raise ValueError("client timeouts must be finite and greater than zero")
148
+ post_timeout = os.environ.get("FD_POST_TIMEOUT")
149
+ self.post_timeout: float | None = None
150
+ if post_timeout is not None:
151
+ self.post_timeout = float(post_timeout)
152
+ if not math.isfinite(self.post_timeout) or self.post_timeout <= 0:
153
+ raise ValueError("FD_POST_TIMEOUT must be finite and greater than zero")
154
+ call_timeout = os.environ.get("FD_CALL_TIMEOUT")
155
+ self.call_timeout: float | None = None
156
+ if call_timeout is not None:
157
+ self.call_timeout = float(call_timeout)
158
+ if not math.isfinite(self.call_timeout) or self.call_timeout <= 0:
159
+ raise ValueError("FD_CALL_TIMEOUT must be finite and greater than zero")
160
+ self._seen: set[str] = set()
161
+ self.last_warmup_ms: float | None = None
162
+ self.cost_models: dict[str, dict[str, float]] = {}
163
+ self.warmup_errors: dict[str, str] = {}
164
+ self._http = httpx.Client(transport=transport, trust_env=False)
165
+
166
+ def __enter__(self) -> "D1Client":
167
+ return self
168
+
169
+ def __exit__(self, *_: Any) -> None:
170
+ self.close()
171
+
172
+ def close(self) -> None:
173
+ self._http.close()
174
+
175
+ def _lane(self, lane: str | None, images: Any = None, audio: Any = None) -> str:
176
+ if images and audio is not None:
177
+ raise ClientError("invalid_response", "images and audio cannot be combined")
178
+ selected = lane or ("omni" if audio is not None else "max")
179
+ if selected not in self.urls:
180
+ raise ClientError("lane_down", "requested lane is unavailable")
181
+ if audio is not None and selected != "omni":
182
+ raise ClientError("invalid_response", "audio requires the omni lane")
183
+ return selected
184
+
185
+ def _request(self, lane: str, method: str, path: str, json_body: dict[str, Any] | None = None, *, consume_budget: bool = True, budget_s: float | None = None) -> tuple[dict[str, Any], float]:
186
+ if budget_s is not None and (not math.isfinite(budget_s) or budget_s <= 0):
187
+ raise ValueError("budget_s must be finite and greater than zero")
188
+ budget = budget_s if budget_s is not None else ((self.first_timeout if lane not in self._seen else self.timeout) if consume_budget else self.timeout)
189
+ if consume_budget:
190
+ self._seen.add(lane)
191
+ started = time.perf_counter()
192
+ attempts = 0
193
+ while True:
194
+ remaining = budget - (time.perf_counter() - started)
195
+ if remaining <= 0:
196
+ raise ClientError("timeout", "decision request timed out")
197
+ try:
198
+ response = self._http.request(method, self.urls[lane] + path, json=json_body, timeout=remaining)
199
+ break
200
+ except httpx.TimeoutException as exc:
201
+ raise ClientError("timeout", "decision request timed out") from exc
202
+ except httpx.RemoteProtocolError as exc:
203
+ if attempts:
204
+ raise ClientError("lane_down", "decision lane disconnected") from exc
205
+ attempts += 1
206
+ except httpx.ReadError as exc:
207
+ if not _connection_reset(exc) or attempts:
208
+ raise ClientError("lane_down", "decision lane disconnected") from exc
209
+ attempts += 1
210
+ except httpx.ConnectError as exc:
211
+ raise ClientError("lane_down", "decision lane is unavailable") from exc
212
+ except httpx.HTTPError as exc:
213
+ raise ClientError("lane_down", "decision lane request failed") from exc
214
+ elapsed = (time.perf_counter() - started) * 1000
215
+ if response.status_code != 200:
216
+ raise ClientError("http_error", f"decision lane returned HTTP {response.status_code}")
217
+ try:
218
+ payload = response.json()
219
+ except ValueError as exc:
220
+ raise ClientError("invalid_response", "invalid response: JSON object expected") from exc
221
+ if not isinstance(payload, dict):
222
+ raise ClientError("invalid_response", "invalid response: JSON object expected")
223
+ return payload, elapsed
224
+
225
+ def system_one(self, state: Any, questions: dict[str, dict[str, Any]], *, images: list[str] | None = None, audio: str | None = None, lane: str | None = None, budget_s: float | None = None) -> dict[str, Any]:
226
+ selected = self._lane(lane, images, audio)
227
+ body: dict[str, Any] = {"model": "d1", "state": state, "questions": questions}
228
+ if images is not None:
229
+ body["images"] = images
230
+ if audio is not None:
231
+ body["audio"] = audio
232
+ payload, elapsed = self._request(selected, "POST", "/v1/systemone", body, budget_s=budget_s)
233
+ validate_response(payload, questions)
234
+ return enrich_response(payload, selected, elapsed, questions)
235
+
236
+ def system_one_batch(self, requests: list[dict[str, Any]], *, lane: str | None = None, budget_s: float | None = None) -> dict[str, Any]:
237
+ if not isinstance(requests, list) or not requests:
238
+ raise ClientError("invalid_response", "requests must be a non-empty list")
239
+ if any(not isinstance(item, dict) or not isinstance(item.get("questions"), dict) or not item["questions"] for item in requests):
240
+ raise ClientError("invalid_response", "batch requests must contain question objects")
241
+ has_audio = any(item.get("audio") is not None for item in requests)
242
+ has_text = any(item.get("audio") is None for item in requests)
243
+ if any(item.get("images") and item.get("audio") is not None for item in requests):
244
+ raise ClientError("invalid_response", "images and audio cannot be combined")
245
+ if lane is None and has_audio and has_text:
246
+ raise ClientError("invalid_response", "mixed audio and text batches require an explicit omni lane")
247
+ selected = self._lane(lane, None, "audio" if has_audio else None)
248
+ body_requests = [dict(item) for item in requests]
249
+ payload, elapsed = self._request(selected, "POST", "/v1/systemone/batch", {"requests": body_requests}, budget_s=budget_s)
250
+ results = payload.get("results") if isinstance(payload, dict) else None
251
+ if not isinstance(results, list) or len(results) != len(requests):
252
+ raise ClientError("invalid_response", "invalid response: batch results")
253
+ enriched = []
254
+ for request, result in zip(requests, results):
255
+ questions = request.get("questions")
256
+ if not isinstance(questions, dict):
257
+ raise ClientError("invalid_response", "invalid response: batch question set")
258
+ validate_response(result, questions)
259
+ enriched.append(enrich_response(result, selected, elapsed, questions))
260
+ return {"results": enriched, "timings": {**(payload.get("timings") or {}), "client_ms": round(elapsed, 1)}, "lane": selected}
261
+
262
+ def warmup(self, lane: str = "max") -> dict[str, Any]:
263
+ started = time.perf_counter()
264
+ self.warmup_errors.pop(lane, None)
265
+ self.cost_models.pop(lane, None)
266
+ try:
267
+ result = self.system_one("Warm-up state.", {"warmup": {"type": "noul", "instructions": "Is this a warm-up?"}}, lane=lane)
268
+ except ClientError as exc:
269
+ self.warmup_errors[lane] = exc.reason
270
+ self.last_warmup_ms = round((time.perf_counter() - started) * 1000, 1)
271
+ raise
272
+ try:
273
+ from .policy import load_pack
274
+ from .scoping import strip_scopes
275
+ post = load_pack("gate")["post_result"]
276
+ policy = post["policy"]
277
+ width = policy["window_chars"]
278
+ fraction = policy["warmup_probe_fraction"]
279
+ questions = post["questions"]
280
+ filler = "This is ordinary benign result text used for latency measurement. "
281
+ quarter = max(1, int(width * fraction))
282
+ probe_timings: list[tuple[int, float]] = []
283
+ for length in (width, quarter):
284
+ probe = self.system_one_batch(
285
+ [{"state": (filler * ((length // len(filler)) + 1))[:length], "questions": questions}],
286
+ lane=lane,
287
+ budget_s=self.first_timeout,
288
+ )
289
+ # system_one_batch reports one elapsed time for the request.
290
+ probe_timings.append((length, float(probe["timings"]["client_ms"])))
291
+ (x1, y1), (x2, y2) = probe_timings
292
+ slope = max(0.0, (y1 - y2) / (x1 - x2)) if x1 != x2 else 0.0
293
+ intercept = max(0.0, y1 - slope * x1, y2 - slope * x2)
294
+ model = {"intercept_ms": intercept, "ms_per_char": slope, "ms_per_question": 0.0}
295
+ # One extra probe isolates the marginal per-question cost: the same
296
+ # quarter-window state asked the gate pack's full question set,
297
+ # compared with the two post-result questions already probed at that
298
+ # length. A failed gate probe leaves ms_per_question at 0.0 rather
299
+ # than discarding the char-based model.
300
+ try:
301
+ gate_questions = strip_scopes(load_pack("gate")["questions"])
302
+ gate_probe = self.system_one_batch(
303
+ [{"state": (filler * ((quarter // len(filler)) + 1))[:quarter], "questions": gate_questions}],
304
+ lane=lane,
305
+ budget_s=self.first_timeout,
306
+ )
307
+ gate_ms = float(gate_probe["timings"]["client_ms"])
308
+ span = len(gate_questions) - len(questions)
309
+ if span > 0:
310
+ model["ms_per_question"] = max(0.0, (gate_ms - y2) / span)
311
+ except (ClientError, OSError, ValueError, KeyError, TypeError) as probe_exc:
312
+ self.warmup_errors[lane] = "per_question_probe:" + getattr(probe_exc, "reason", type(probe_exc).__name__)
313
+ self.cost_models[lane] = model
314
+ except (ClientError, OSError, ValueError, KeyError, TypeError) as exc:
315
+ self.cost_models.pop(lane, None)
316
+ self.warmup_errors[lane] = getattr(exc, "reason", type(exc).__name__)
317
+ self.last_warmup_ms = round((time.perf_counter() - started) * 1000, 1)
318
+ return result
319
+
320
+ def request_budget(self, total_chars: int, question_count: int, policy: dict[str, Any],
321
+ lane: str = "max", *, override: float | None = None) -> dict[str, Any]:
322
+ """Derive a per-call budget from the measured cost model and a YAML policy.
323
+
324
+ Expected cost is ``intercept_ms + ms_per_char * total_chars +
325
+ ms_per_question * question_count``. ``override`` is an optional fixed
326
+ budget (from ``FD_CALL_TIMEOUT`` or ``FD_POST_TIMEOUT``); it is still
327
+ capped by the policy. When no cost model exists the budget falls back to
328
+ the fixed timeout and ``budget_source`` records that. A known over-cap
329
+ cost (or a fixed override smaller than the required budget) sets
330
+ ``exceeds_budget`` so the caller can decline to send."""
331
+ if not isinstance(total_chars, int) or isinstance(total_chars, bool) or total_chars < 0:
332
+ raise ValueError("total_chars must be a non-negative integer")
333
+ if not isinstance(question_count, int) or isinstance(question_count, bool) or question_count < 0:
334
+ raise ValueError("question_count must be a non-negative integer")
335
+ try:
336
+ margin = float(policy["budget_margin"])
337
+ maximum = float(policy["max_budget_s"])
338
+ except (KeyError, TypeError, ValueError) as exc:
339
+ raise ValueError("budget policy is invalid") from exc
340
+ if not math.isfinite(margin) or margin <= 0 or not math.isfinite(maximum) or maximum <= 0:
341
+ raise ValueError("budget policy must be finite and greater than zero")
342
+ model = self.cost_models.get(lane)
343
+ expected_ms = None
344
+ required_s = None
345
+ if model is not None:
346
+ per_question = float(model.get("ms_per_question", 0.0))
347
+ expected_ms = max(0.0, model["intercept_ms"] + model["ms_per_char"] * total_chars
348
+ + per_question * question_count)
349
+ required_s = margin * expected_ms / 1000.0
350
+ if override is not None:
351
+ source = "override"
352
+ budget_s = min(override, maximum)
353
+ elif required_s is not None:
354
+ source = "measured"
355
+ budget_s = min(max(self.timeout, required_s), maximum)
356
+ else:
357
+ source = "fallback"
358
+ budget_s = min(override if override is not None else self.timeout, maximum)
359
+ exceeds = required_s is not None and (required_s > maximum or (override is not None and override < required_s))
360
+ return {"budget_s": budget_s, "budget_source": source, "expected_ms": expected_ms,
361
+ "exceeds_budget": exceeds, "total_chars": total_chars, "questions": question_count}
362
+
363
+ def post_result_budget(self, total_chars: int, policy: dict[str, Any], lane: str = "max") -> dict[str, Any]:
364
+ """Thin wrapper preserving IP-003 behaviour: post-result questions carry
365
+ no per-question term, and ``FD_POST_TIMEOUT`` is the fixed override."""
366
+ return self.request_budget(total_chars, 0, policy, lane, override=self.post_timeout)
367
+
368
+ def health(self) -> dict[str, Any]:
369
+ result: dict[str, Any] = {"last_warmup_ms": self.last_warmup_ms, "cost_models": copy.deepcopy(self.cost_models), "warmup_errors": dict(self.warmup_errors)}
370
+ for lane in self.urls:
371
+ try:
372
+ payload, elapsed = self._request(lane, "GET", "/health", consume_budget=False)
373
+ result[lane] = {**payload, "lane": lane, "timings": {"client_ms": round(elapsed, 1)}}
374
+ except ClientError as exc:
375
+ result[lane] = {"lane": lane, "error": {"reason": exc.reason, "message": str(exc)}}
376
+ return result
fluid/hook.py ADDED
@@ -0,0 +1,56 @@
1
+ """One-shot stdin hook adapter for pre-tool and post-result events."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import sys
6
+ from typing import Any
7
+
8
+ from .cli import _result_exit, _service
9
+
10
+
11
+ def _ask(reason: str, message: str) -> dict[str, Any]:
12
+ return {"verdict": "ask", "error": {"reason": reason, "message": message}}
13
+
14
+
15
+ def handle(event: Any, service: Any | None = None) -> dict[str, Any]:
16
+ if not isinstance(event, dict):
17
+ return _ask("invalid_input", "hook event must be a JSON object")
18
+ kind = event.get("event")
19
+ try:
20
+ if kind == "pre_tool":
21
+ if not isinstance(event.get("tool_name"), str) or not isinstance(event.get("tool_input"), dict):
22
+ return _ask("invalid_input", "pre_tool requires tool_name and object tool_input")
23
+ return (service or _service()).gate(
24
+ event["tool_name"], event["tool_input"], user_request=event.get("user_request", ""), session_id=event.get("session_id")
25
+ )
26
+ if kind == "post_result":
27
+ if not isinstance(event.get("result"), str) or not isinstance(event.get("session_id"), str):
28
+ return _ask("invalid_input", "post_result requires session_id and string result")
29
+ return (service or _service()).post_result(event["result"], session_id=event["session_id"], tool_name=event.get("tool_name", ""))
30
+ return _ask("invalid_input", "event must be pre_tool or post_result")
31
+ except Exception as exc:
32
+ return _ask("service_failure", str(exc))
33
+
34
+
35
+ def main() -> int:
36
+ try:
37
+ event = json.loads(sys.stdin.read())
38
+ except Exception as exc:
39
+ result = _ask("invalid_input", f"invalid JSON: {exc}")
40
+ print(json.dumps(result, separators=(",", ":")))
41
+ return 2
42
+ service = None
43
+ try:
44
+ service = _service()
45
+ result = handle(event, service)
46
+ except Exception:
47
+ result = _ask('service_failure', 'hook configuration or service unavailable')
48
+ finally:
49
+ if service is not None:
50
+ service.close()
51
+ print(json.dumps(result, ensure_ascii=False, separators=(",", ":")))
52
+ return _result_exit(result, 'hook')
53
+
54
+
55
+ if __name__ == "__main__": # pragma: no cover
56
+ raise SystemExit(main())
fluid/lanes.yaml ADDED
@@ -0,0 +1,8 @@
1
+ # Portable defaults. Override with FLUID_LANES_FILE or FD_*_URL.
2
+ lanes:
3
+ max:
4
+ url: http://127.0.0.1:8774
5
+ modalities: [text, image]
6
+ omni:
7
+ url: http://127.0.0.1:8775
8
+ modalities: [text, image, audio]