hunch-engine 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.
hunch/questions.py ADDED
@@ -0,0 +1,79 @@
1
+ """Backend-neutral question and answer types. The client maps these to SDK types."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import dataclass
7
+ from typing import Any
8
+
9
+ JSON = dict[str, Any]
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class NoulQ:
14
+ instructions: str
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class ChoiceQ:
19
+ instructions: str
20
+ options: tuple[str, ...]
21
+ descriptions: Mapping[str, str] | None = (
22
+ None # option -> what it means; sent as Choice criteria
23
+ )
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class ScoreQ:
28
+ instructions: str
29
+ levels: tuple[str, ...]
30
+
31
+
32
+ Question = NoulQ | ChoiceQ | ScoreQ
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class NoulA:
37
+ probability: float
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class ChoiceA:
42
+ choice: str
43
+ confidence: float
44
+ probabilities: Mapping[str, float]
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class ScoreA:
49
+ score: float
50
+ confidence: float
51
+ probabilities: Mapping[int, float]
52
+
53
+
54
+ Answer = NoulA | ChoiceA | ScoreA
55
+
56
+
57
+ @dataclass(frozen=True)
58
+ class Answers:
59
+ model: str
60
+ answers: Mapping[str, Answer]
61
+ input_tokens: int | None
62
+
63
+ def noul(self, question_id: str) -> float:
64
+ a = self.answers[question_id]
65
+ if not isinstance(a, NoulA):
66
+ raise TypeError(f"{question_id} is {type(a).__name__}, expected NoulA")
67
+ return a.probability
68
+
69
+ def choice(self, question_id: str) -> ChoiceA:
70
+ a = self.answers[question_id]
71
+ if not isinstance(a, ChoiceA):
72
+ raise TypeError(f"{question_id} is {type(a).__name__}, expected ChoiceA")
73
+ return a
74
+
75
+ def score(self, question_id: str) -> ScoreA:
76
+ a = self.answers[question_id]
77
+ if not isinstance(a, ScoreA):
78
+ raise TypeError(f"{question_id} is {type(a).__name__}, expected ScoreA")
79
+ return a
hunch/resolution.py ADDED
@@ -0,0 +1,138 @@
1
+ """What the engine returns. No free text anywhere; every variant carries the full trace."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Mapping
6
+ from dataclasses import asdict, dataclass, field
7
+
8
+ from hunch.model import Entity
9
+ from hunch.questions import JSON, Answer, Answers, ChoiceA, NoulA, ScoreA
10
+ from hunch.vocabulary import Verb
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class TraceEntry:
15
+ round: int
16
+ question_id: str
17
+ answer: Answer
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class ThresholdDecision:
22
+ name: str
23
+ value: float
24
+ threshold: float
25
+ passed: bool
26
+
27
+
28
+ @dataclass
29
+ class Trace:
30
+ """Mutable builder; the only non-frozen type in the package.
31
+
32
+ Frozen when embedded via to_dict.
33
+ """
34
+
35
+ entries: list[TraceEntry] = field(default_factory=list)
36
+ decisions: list[ThresholdDecision] = field(default_factory=list)
37
+ models: list[str] = field(default_factory=list)
38
+ notes: list[str] = field(default_factory=list)
39
+ input_tokens: list[int | None] = field(default_factory=list)
40
+
41
+ def record(self, round: int, answers: Answers) -> None:
42
+ self.models.append(answers.model)
43
+ self.input_tokens.append(answers.input_tokens)
44
+ for qid, ans in answers.answers.items():
45
+ self.entries.append(TraceEntry(round, qid, ans))
46
+
47
+ def decide(self, name: str, value: float, threshold: float) -> bool:
48
+ passed = value >= threshold
49
+ self.decisions.append(ThresholdDecision(name, value, threshold, passed))
50
+ return passed
51
+
52
+ def note(self, text: str) -> None:
53
+ self.notes.append(text)
54
+
55
+ def to_dict(self) -> JSON:
56
+ return {
57
+ "models": list(self.models),
58
+ "entries": [
59
+ {"round": e.round, "question_id": e.question_id, "answer": _answer_dict(e.answer)}
60
+ for e in self.entries
61
+ ],
62
+ "decisions": [asdict(d) for d in self.decisions],
63
+ "notes": list(self.notes),
64
+ "input_tokens": list(self.input_tokens),
65
+ }
66
+
67
+
68
+ def _answer_dict(a: Answer) -> JSON:
69
+ if isinstance(a, NoulA):
70
+ return {"type": "noul", "probability": a.probability}
71
+ if isinstance(a, ChoiceA):
72
+ return {
73
+ "type": "choice",
74
+ "choice": a.choice,
75
+ "confidence": a.confidence,
76
+ "probabilities": dict(a.probabilities),
77
+ }
78
+ if isinstance(a, ScoreA):
79
+ return {
80
+ "type": "score",
81
+ "score": a.score,
82
+ "confidence": a.confidence,
83
+ "probabilities": {str(k): v for k, v in a.probabilities.items()},
84
+ }
85
+ raise TypeError(type(a))
86
+
87
+
88
+ @dataclass(frozen=True)
89
+ class Action:
90
+ verb: Verb
91
+ targets: tuple[Entity, ...]
92
+ params: Mapping[str, float | str]
93
+
94
+
95
+ @dataclass(frozen=True)
96
+ class Condition:
97
+ subject: Entity
98
+ expected_state: str
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class Resolved:
103
+ actions: tuple[Action, ...]
104
+ condition: Condition | None
105
+ confidence: float
106
+ trace: Trace
107
+
108
+
109
+ @dataclass(frozen=True)
110
+ class NeedsConfirmation:
111
+ actions: tuple[Action, ...]
112
+ condition: Condition | None
113
+ reason: str
114
+ trace: Trace
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class NeedsClarification:
119
+ question_key: str # "which_area" | "which_device"
120
+ candidates: tuple[Entity, ...]
121
+ trace: Trace
122
+ # the verb being clarified and the params already resolved for it ({} when the
123
+ # clarification happened before Round 2). Lets a caller act on the user's pick.
124
+ verb: Verb | None = None
125
+ params: Mapping[str, float | str] = field(default_factory=dict)
126
+
127
+
128
+ @dataclass(frozen=True)
129
+ class Escalate:
130
+ # reason: "timing" | "no_intent" | "destructive" | "low_confidence" | "scope"
131
+ # | "round_budget" | "condition" | "exception" | "relative_change" | "query_collective"
132
+ # | "decision_backend_unavailable" | "prompt_invalid"
133
+ reason: str
134
+ partial: tuple[Action, ...]
135
+ trace: Trace
136
+
137
+
138
+ Resolution = Resolved | NeedsConfirmation | NeedsClarification | Escalate
hunch/resolver.py ADDED
@@ -0,0 +1,351 @@
1
+ """Turn probabilities into a Resolution. Confidence is the minimum of everything we relied on."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from hunch.config import EngineConfig
8
+ from hunch.model import Entity
9
+ from hunch.phrasing import EN, Phrasebook
10
+ from hunch.questions import Answers
11
+ from hunch.resolution import (
12
+ Action,
13
+ Condition,
14
+ Escalate,
15
+ NeedsClarification,
16
+ NeedsConfirmation,
17
+ Resolution,
18
+ Resolved,
19
+ Trace,
20
+ )
21
+ from hunch.round1 import Shape
22
+ from hunch.round2 import NO_MATCH, Round2Plan, parse_number
23
+ from hunch.vocabulary import ChoiceSpec, Risk, ScoreSpec, Verb
24
+
25
+
26
+ def score_to_value(spec: ScoreSpec, score: float) -> float:
27
+ idx = score # Jev scores are 0-based: a level's position in the criteria list
28
+ idx = max(0.0, min(float(len(spec.values) - 1), idx)) # saturate both ends
29
+ lo = max(0, min(len(spec.values) - 1, int(idx)))
30
+ hi = min(len(spec.values) - 1, lo + 1)
31
+ frac = idx - lo
32
+ return spec.values[lo] + (spec.values[hi] - spec.values[lo]) * frac
33
+
34
+
35
+ def _scope_strength(shape: Shape, trace: Trace) -> float:
36
+ """The strongest Round 1 signal that put areas into scope: an area or a floor probability."""
37
+ best = max((shape.area_probs.get(a, 0.0) for a in shape.scope_areas), default=0.0)
38
+ for d in trace.decisions:
39
+ if d.name.startswith("floor:") and d.passed:
40
+ best = max(best, d.value)
41
+ return best
42
+
43
+
44
+ _BOUNDS = {"percent": (0.0, 100.0), "fraction": (0.0, 100.0), "degrees": (5.0, 35.0)}
45
+
46
+
47
+ def _numeric_value(spec: ScoreSpec, number: float, *, inverted: bool) -> float | None:
48
+ """Code calculates: apply the mode Jev chose and the plausibility bounds of the unit."""
49
+ lo, hi = _BOUNDS.get(spec.kind, (float("-inf"), float("inf")))
50
+ if not lo <= number <= hi:
51
+ return None
52
+ if spec.kind == "fraction":
53
+ number = number / 100.0
54
+ if inverted and spec.kind == "percent":
55
+ number = 100.0 - number
56
+ return number
57
+
58
+
59
+ def _base_label(label: str) -> str:
60
+ """'Dachterrasse Rollo (Galerie)' -> 'Dachterrasse Rollo'; '#2' suffixes likewise."""
61
+ return re.sub(r" (?:\([^)]*\)|#\d+)$", "", label)
62
+
63
+
64
+ def _all_entities(options) -> tuple[Entity, ...]:
65
+ """Every entity behind a set of options, once — a device option and its per-entity options
66
+ describe the same lamps."""
67
+ seen: dict[str, Entity] = {}
68
+ for o in options:
69
+ for e in o.entities:
70
+ seen.setdefault(e.entity_id, e)
71
+ return tuple(seen.values())
72
+
73
+
74
+ def _verb_prob(trace: Trace, verb_name: str) -> float:
75
+ for d in reversed(trace.decisions):
76
+ if d.name == f"verb:{verb_name}":
77
+ return d.value
78
+ # Invariant: Round 1 asks a Noul for every verb in the vocabulary and records the
79
+ # decision, so a fired verb always has one. If it somehow does not, fail closed —
80
+ # an unverified verb must never be treated as certain.
81
+ return 0.0
82
+
83
+
84
+ def resolve(
85
+ shape: Shape,
86
+ plan: Round2Plan,
87
+ round2: Answers | None,
88
+ config: EngineConfig,
89
+ trace: Trace,
90
+ pb: Phrasebook = EN,
91
+ ) -> Resolution:
92
+ th = config.thresholds
93
+ if round2 is not None:
94
+ trace.record(2, round2)
95
+
96
+ if trace.decide("flag:is_destructive", shape.flag("is_destructive"), th.flag) or any(
97
+ v.risk is Risk.DESTRUCTIVE for v in shape.fired_verbs
98
+ ):
99
+ return Escalate("destructive", (), trace)
100
+
101
+ actions: list[Action] = []
102
+ contributions: list[float] = []
103
+ reasons: list[str] = []
104
+ pending_clarify: tuple[Entity, ...] | None = None # only used if no verb yields an action
105
+ pending_clarify_verb: Verb | None = None
106
+ pending_clarify_params: dict[str, float | str] = {}
107
+ exception_unresolved = False
108
+ relative_change = False
109
+
110
+ for verb in shape.fired_verbs:
111
+ # Per-verb contributions stay local until the verb actually yields targets: a verb
112
+ # that resolves to nothing must not drag down the confidence of the ones that did.
113
+ local: list[float] = [_verb_prob(trace, verb.name)]
114
+ targets: tuple[Entity, ...] = ()
115
+
116
+ if verb.name in plan.outside_scope and round2 is not None:
117
+ p_out = round2.noul(f"outside_scope:{verb.name}")
118
+ if not trace.decide(f"outside_scope:{verb.name}", p_out, th.flag):
119
+ trace.note(f"dropped:{verb.name}:outside_scope")
120
+ continue
121
+ local.append(p_out)
122
+
123
+ if verb.name in plan.collective:
124
+ targets = plan.collective[verb.name]
125
+ if verb.name in plan.include and round2 is not None:
126
+ kept_inc: list[Entity] = []
127
+ for e in targets:
128
+ p_in = round2.noul(f"include:{verb.name}:{e.entity_id}")
129
+ if trace.decide(f"include:{verb.name}:{e.entity_id}", p_in, 0.5):
130
+ kept_inc.append(e)
131
+ local.append(p_in)
132
+ else:
133
+ trace.note(f"not_meant:{verb.name}:{e.entity_id}")
134
+ targets = tuple(kept_inc)
135
+ if len(targets) > 1: # a single candidate never relied on the collective flag
136
+ collective = shape.flag("collective")
137
+ in_scope = shape.scope_areas and all(
138
+ e.area_id in shape.scope_areas for e in targets
139
+ )
140
+ if in_scope:
141
+ # The room or floor was said out loud (or a floor fired) and everything we
142
+ # are about to touch is inside it: two signals agree on "all of them".
143
+ named = any(n.startswith("area_match:") for n in trace.notes)
144
+ backing = 1.0 if named else _scope_strength(shape, trace)
145
+ if backing > collective:
146
+ trace.note(f"collective_backed_by_scope:{verb.name}")
147
+ collective = backing
148
+ local.append(collective)
149
+ elif verb.name in plan.exclude and round2 is not None:
150
+ kept: list[Entity] = []
151
+ # The set was justified by the plural OR by the exception ("Licht aus außer …" has
152
+ # no plural); count whichever signal carried it, plus the exception itself.
153
+ local.append(max(shape.flag("collective"), shape.flag("has_exception")))
154
+ local.append(shape.flag("has_exception"))
155
+ for e in plan.exclude[verb.name]:
156
+ p = round2.noul(f"exclude:{verb.name}:{e.entity_id}")
157
+ excluded = trace.decide(f"exclude:{verb.name}:{e.entity_id}", p, 0.5)
158
+ local.append(p if excluded else 1.0 - p)
159
+ if not excluded:
160
+ kept.append(e)
161
+ targets = tuple(kept)
162
+ if len(kept) == len(plan.exclude[verb.name]):
163
+ # 'except X' named something we could not find: never sweep past it silently.
164
+ trace.note(f"exception_unresolved:{verb.name}")
165
+ exception_unresolved = True
166
+ elif verb.name in plan.singular and round2 is not None:
167
+ options = plan.singular[verb.name]
168
+ c = round2.choice(f"target:{verb.name}")
169
+ trace.decide(f"target:{verb.name}", c.confidence, th.target_choice_conf)
170
+ ranked = sorted(
171
+ (o for o in options if o.label != NO_MATCH),
172
+ key=lambda o: -c.probabilities.get(o.label, 0.0),
173
+ )
174
+ p_all = (
175
+ round2.noul(f"all_of:{verb.name}")
176
+ if f"all_of:{verb.name}" in round2.answers
177
+ else 0.0
178
+ )
179
+ if trace.decide(f"all_of:{verb.name}", p_all, th.collective):
180
+ # Jev saw the candidates and says the request means every one of them.
181
+ trace.note(f"all_of:{verb.name}")
182
+ targets = _all_entities(options)
183
+ backing = p_all
184
+ if shape.scope_areas and all(e.area_id in shape.scope_areas for e in targets):
185
+ named = any(n.startswith("area_match:") for n in trace.notes)
186
+ backing = max(p_all, 1.0 if named else _scope_strength(shape, trace))
187
+ if backing > p_all:
188
+ trace.note(f"collective_backed_by_scope:{verb.name}")
189
+ local.append(backing)
190
+ elif c.choice == NO_MATCH:
191
+ trace.note(f"no_match:target:{verb.name}")
192
+ if trace.decide(
193
+ f"collective_fallback:{verb.name}",
194
+ max(shape.flag("collective"), p_all),
195
+ th.collective_fallback,
196
+ ):
197
+ # Some plural signal, no single target: all of them — but ask first.
198
+ trace.note(f"collective_fallback:{verb.name}")
199
+ targets = _all_entities(options)
200
+ local.append(max(shape.flag("collective"), p_all))
201
+ reasons.append("collective_fallback")
202
+ elif c.confidence < th.no_match_clarify:
203
+ candidates = tuple(e for o in ranked for e in o.entities)
204
+ trace.note(f"clarify:target:{verb.name}")
205
+ if pending_clarify is None:
206
+ pending_clarify = candidates[: config.clarify_max_candidates]
207
+ pending_clarify_verb = verb
208
+ else:
209
+ local.append(c.confidence)
210
+ else:
211
+ opt = next((o for o in options if o.label == c.choice), None)
212
+ weak = c.confidence < th.confirm_band and len(ranked) > 1
213
+ twins: list = []
214
+ if opt is not None:
215
+ # Same device name in several rooms: unless the scope singles out the picked
216
+ # one's room, nothing in the request can tell them apart, whatever the pick's
217
+ # confidence. A lookup, not a judgement.
218
+ base = _base_label(opt.label)
219
+ same = [o for o in options if o is not opt and _base_label(o.label) == base]
220
+ named = any(n.startswith("area_match:") for n in trace.notes)
221
+ in_scope = [
222
+ o
223
+ for o in same
224
+ if not named
225
+ or not shape.scope_areas
226
+ or o.entities[0].area_id in shape.scope_areas
227
+ ]
228
+ twins = in_scope
229
+ if opt is not None and (weak or twins or verb.name in plan.ambiguous_by_area):
230
+ if not weak:
231
+ trace.note(f"clarify:ambiguous_by_area:{verb.name}")
232
+ if twins:
233
+ ranked = [opt, *twins]
234
+ # A weak pick among real alternatives, or same-named devices in different
235
+ # rooms with no room said: asking beats guessing or giving up.
236
+ ordered = [opt, *(o for o in ranked if o is not opt)]
237
+ candidates = tuple(e for o in ordered for e in o.entities)
238
+ trace.note(f"clarify:weak_pick:{verb.name}")
239
+ if pending_clarify is None:
240
+ pending_clarify = candidates[: config.clarify_max_candidates]
241
+ pending_clarify_verb = verb
242
+ else:
243
+ targets = opt.entities if opt else ()
244
+ local.append(c.confidence)
245
+ elif shape.scene is not None and verb.name == "activate":
246
+ targets = (shape.scene,)
247
+
248
+ params: dict[str, float | str] = {}
249
+ spec = verb.param
250
+ if (
251
+ isinstance(spec, ScoreSpec)
252
+ and round2 is not None
253
+ and f"param_value:{verb.name}" in round2.answers
254
+ ):
255
+ # Jev judged which number (if any) is the value and how it is meant; code computes.
256
+ picked = round2.choice(f"param_value:{verb.name}")
257
+ number = parse_number(picked.choice) if picked.choice != NO_MATCH else None
258
+ if number is None:
259
+ trace.note(f"param:number_rejected:{verb.name}")
260
+ else:
261
+ p_rel = round2.noul(f"param_relative:{verb.name}")
262
+ relative = trace.decide(f"param_relative:{verb.name}", p_rel, th.flag)
263
+ if relative:
264
+ trace.note(f"param:relative:{verb.name}:{number}")
265
+ relative_change = True
266
+ else:
267
+ inverted = False
268
+ inv_conf = 1.0
269
+ if f"param_inverted:{verb.name}" in round2.answers:
270
+ p_inv = round2.noul(f"param_inverted:{verb.name}")
271
+ inverted = trace.decide(f"param_inverted:{verb.name}", p_inv, 0.5)
272
+ inv_conf = p_inv if inverted else 1.0 - p_inv
273
+ value = _numeric_value(spec, number, inverted=inverted)
274
+ if value is None:
275
+ trace.note(f"param:out_of_bounds:{verb.name}")
276
+ else:
277
+ params[spec.name] = value
278
+ local.extend((picked.confidence, 1.0 - p_rel, inv_conf))
279
+ trace.note(f"param:number:{verb.name}:{value}")
280
+ if not params and (
281
+ spec is not None and round2 is not None and f"param:{verb.name}" in round2.answers
282
+ ):
283
+ if isinstance(spec, ScoreSpec):
284
+ sc = round2.score(f"param:{verb.name}")
285
+ params[spec.name] = score_to_value(spec, sc.score)
286
+ local.append(sc.confidence)
287
+ elif isinstance(spec, ChoiceSpec):
288
+ cc = round2.choice(f"param:{verb.name}")
289
+ params[spec.name] = cc.choice
290
+ local.append(cc.confidence)
291
+
292
+ if pending_clarify_verb is verb and not pending_clarify_params:
293
+ pending_clarify_params = dict(params)
294
+
295
+ if not targets:
296
+ trace.note(f"dropped:{verb.name}")
297
+ continue
298
+ contributions.extend(local)
299
+ actions.append(Action(verb, targets, params))
300
+ if verb.risk is Risk.CONFIRM:
301
+ reasons.append("risk:confirm")
302
+ if len(targets) > config.max_silent_targets:
303
+ reasons.append("blast_radius")
304
+
305
+ condition: Condition | None = None
306
+ if plan.condition_candidates and round2 is not None and "cond_subject" in round2.answers:
307
+ subj = round2.choice("cond_subject")
308
+ state = round2.choice("cond_state")
309
+ contributions.extend((subj.confidence, state.confidence))
310
+ if subj.choice == NO_MATCH:
311
+ trace.note("no_match:cond_subject")
312
+ elif state.choice == NO_MATCH:
313
+ trace.note("no_match:cond_state")
314
+ else:
315
+ opt = next((o for o in plan.condition_options if o.label == subj.choice), None)
316
+ if opt:
317
+ condition = Condition(opt.entities[0], state.choice)
318
+
319
+ if exception_unresolved:
320
+ return Escalate("exception", tuple(actions), trace)
321
+ if relative_change:
322
+ # The engine does not know current brightness/position/setpoints; the fallback agent does.
323
+ return Escalate("relative_change", tuple(actions), trace)
324
+ if condition is None and trace.decide(
325
+ "flag:has_condition", shape.flag("has_condition"), th.flag
326
+ ):
327
+ # The request depends on something we could not turn into a Condition. Executing it
328
+ # unconditionally would be wrong; the fallback agent can handle the 'if'.
329
+ trace.note("condition_unresolved")
330
+ return Escalate("condition", tuple(actions), trace)
331
+
332
+ if not actions:
333
+ if pending_clarify:
334
+ # Nothing resolved and one target Choice spread its mass over real options: ask.
335
+ return NeedsClarification(
336
+ "which_device", pending_clarify, trace, pending_clarify_verb, pending_clarify_params
337
+ )
338
+ return Escalate("low_confidence", (), trace)
339
+ if pending_clarify:
340
+ trace.note("clarify_suppressed:other_verbs_resolved")
341
+
342
+ confidence = min(contributions) if contributions else 0.0
343
+ trace.decide("confidence", confidence, th.auto_execute)
344
+
345
+ if reasons:
346
+ return NeedsConfirmation(tuple(actions), condition, reasons[0], trace)
347
+ if confidence >= th.auto_execute:
348
+ return Resolved(tuple(actions), condition, confidence, trace)
349
+ if confidence >= th.confirm_band:
350
+ return NeedsConfirmation(tuple(actions), condition, "confidence", trace)
351
+ return Escalate("low_confidence", tuple(actions), trace)