dos-kernel 0.22.0__py3-none-win_amd64.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.
- dos/__init__.py +261 -0
- dos/_bin/dos-hook.exe +0 -0
- dos/_filelock.py +255 -0
- dos/_job_policy.py +97 -0
- dos/_tree.py +145 -0
- dos/admission.py +433 -0
- dos/answer_shape.py +299 -0
- dos/arbiter.py +859 -0
- dos/archive_lock.py +266 -0
- dos/arg_provenance.py +814 -0
- dos/attest.py +472 -0
- dos/breaker.py +311 -0
- dos/churn.py +226 -0
- dos/claim_extract.py +229 -0
- dos/claim_ttl.py +150 -0
- dos/cli.py +8721 -0
- dos/commit_audit.py +666 -0
- dos/completion.py +466 -0
- dos/concurrency_class.py +154 -0
- dos/config.py +1380 -0
- dos/config_lint.py +464 -0
- dos/cooldown.py +390 -0
- dos/coverage.py +387 -0
- dos/dangling_intent.py +287 -0
- dos/data_class.py +397 -0
- dos/decisions.py +1274 -0
- dos/decisions_tui.py +251 -0
- dos/dispatch_top.py +740 -0
- dos/dispatch_top_tui.py +116 -0
- dos/drivers/__init__.py +40 -0
- dos/drivers/ci_status.py +630 -0
- dos/drivers/citation_resolve.py +703 -0
- dos/drivers/decision_stop.py +98 -0
- dos/drivers/export_file.py +173 -0
- dos/drivers/export_otlp.py +275 -0
- dos/drivers/export_statsd.py +242 -0
- dos/drivers/hook_dialects.py +391 -0
- dos/drivers/job.py +47 -0
- dos/drivers/llm_judge.py +360 -0
- dos/drivers/memory_recall.py +1231 -0
- dos/drivers/notify_slack.py +373 -0
- dos/drivers/notify_webhook.py +251 -0
- dos/drivers/operator_judge.py +114 -0
- dos/drivers/os_acceptance.py +228 -0
- dos/drivers/paste_log.py +132 -0
- dos/drivers/plan_scope.py +133 -0
- dos/drivers/self_improve.py +375 -0
- dos/drivers/similarity_judge.py +249 -0
- dos/drivers/state_diff.py +274 -0
- dos/drivers/supervisor.py +347 -0
- dos/drivers/watchdog.py +363 -0
- dos/drivers/workshop.py +160 -0
- dos/durable_schema.py +344 -0
- dos/effect_witness.py +393 -0
- dos/efficiency.py +318 -0
- dos/enforce.py +414 -0
- dos/enumerate.py +776 -0
- dos/env_print.py +378 -0
- dos/event_severity.py +258 -0
- dos/evidence.py +692 -0
- dos/exec_capability.py +256 -0
- dos/export_cursor.py +143 -0
- dos/exporter.py +320 -0
- dos/firing_label.py +353 -0
- dos/fleet_roll.py +226 -0
- dos/gate_classify.py +827 -0
- dos/gh4_coverage.py +179 -0
- dos/git_delta.py +122 -0
- dos/guard.py +215 -0
- dos/health.py +552 -0
- dos/help_summary.py +519 -0
- dos/home.py +934 -0
- dos/hook_binary.py +194 -0
- dos/hook_dialect.py +271 -0
- dos/hook_exit.py +191 -0
- dos/hook_install.py +437 -0
- dos/id_alloc.py +304 -0
- dos/improve.py +499 -0
- dos/intent_ledger.py +635 -0
- dos/interpret.py +176 -0
- dos/intervention.py +769 -0
- dos/intervention_eval.py +371 -0
- dos/journal_delta.py +308 -0
- dos/judge_eval.py +328 -0
- dos/judges.py +366 -0
- dos/lane_infer.py +127 -0
- dos/lane_journal.py +1001 -0
- dos/lane_lease.py +952 -0
- dos/lane_overlap.py +228 -0
- dos/lease_health.py +282 -0
- dos/lifecycle.py +211 -0
- dos/liveness.py +352 -0
- dos/lock_modes.py +185 -0
- dos/log_source.py +395 -0
- dos/loop_decide.py +1746 -0
- dos/marker_gate.py +254 -0
- dos/marker_sensor.py +396 -0
- dos/noop_streak.py +280 -0
- dos/notify.py +479 -0
- dos/observe.py +175 -0
- dos/oracle.py +1661 -0
- dos/overlap_eval.py +214 -0
- dos/overlap_policy.py +342 -0
- dos/packet_sidecar.py +267 -0
- dos/phase_shipped.py +1985 -0
- dos/pick_priority.py +225 -0
- dos/pickable.py +369 -0
- dos/picker_oracle.py +1037 -0
- dos/plan_board.py +513 -0
- dos/plan_board_tui.py +113 -0
- dos/plan_source.py +455 -0
- dos/posttool_sensor.py +528 -0
- dos/precursor_gate.py +499 -0
- dos/precursor_gate_eval.py +239 -0
- dos/preflight.py +825 -0
- dos/pretool_sensor.py +490 -0
- dos/proc_delta.py +181 -0
- dos/productivity.py +296 -0
- dos/provider_limit.py +242 -0
- dos/py.typed +4 -0
- dos/reason_morphology.py +299 -0
- dos/reasons.py +449 -0
- dos/reconcile.py +173 -0
- dos/recurring_wedge.py +206 -0
- dos/render.py +393 -0
- dos/result_state.py +468 -0
- dos/resume.py +578 -0
- dos/resume_evidence.py +293 -0
- dos/retention.py +344 -0
- dos/reward.py +372 -0
- dos/rewind.py +587 -0
- dos/rewind_evidence.py +168 -0
- dos/rewind_tokens.py +252 -0
- dos/run_id.py +342 -0
- dos/scope.py +520 -0
- dos/scope_source.py +382 -0
- dos/scout.py +982 -0
- dos/self_modify.py +209 -0
- dos/sibling_scan.py +569 -0
- dos/skills/EXAMPLES.md +584 -0
- dos/skills/dos-class-cycle/SKILL.md +107 -0
- dos/skills/dos-dispatch/SKILL.md +177 -0
- dos/skills/dos-dispatch-loop/SKILL.md +254 -0
- dos/skills/dos-goal-gate/SKILL.md +269 -0
- dos/skills/dos-next-up/SKILL.md +231 -0
- dos/skills/dos-promote/SKILL.md +114 -0
- dos/skills/dos-replan/SKILL.md +159 -0
- dos/skills/dos-replan-loop/SKILL.md +114 -0
- dos/skills/dos-self-improve/SKILL.md +213 -0
- dos/skills/dos-supervise-loop/SKILL.md +180 -0
- dos/skills/dos-unstick/SKILL.md +108 -0
- dos/skills/dos-witness-claim/SKILL.md +251 -0
- dos/stamp.py +1002 -0
- dos/state_health.py +387 -0
- dos/status.py +114 -0
- dos/stop_policy.py +334 -0
- dos/supervise.py +1014 -0
- dos/testwitness.py +392 -0
- dos/timeline.py +1027 -0
- dos/tokens.py +485 -0
- dos/tool_stream.py +393 -0
- dos/tool_stream_eval.py +226 -0
- dos/trace.py +524 -0
- dos/verdict.py +140 -0
- dos/verdict_cli.py +189 -0
- dos/verdict_journal.py +497 -0
- dos/verdict_rollup.py +217 -0
- dos/verdicts.py +181 -0
- dos/wedge_reason.py +282 -0
- dos_kernel-0.22.0.dist-info/METADATA +859 -0
- dos_kernel-0.22.0.dist-info/RECORD +178 -0
- dos_kernel-0.22.0.dist-info/WHEEL +5 -0
- dos_kernel-0.22.0.dist-info/entry_points.txt +39 -0
- dos_kernel-0.22.0.dist-info/licenses/LICENSE +21 -0
- dos_kernel-0.22.0.dist-info/top_level.txt +2 -0
- dos_mcp/__init__.py +52 -0
- dos_mcp/py.typed +2 -0
- dos_mcp/server.py +779 -0
dos/completion.py
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
"""completion — the live completion verdict: is the WHOLE job verifiably done? (docs/117).
|
|
2
|
+
|
|
3
|
+
The gap this closes
|
|
4
|
+
===================
|
|
5
|
+
|
|
6
|
+
Every agentic loop today terminates on **budget**, not on **done**. The proof is
|
|
7
|
+
in the kernel's own stop vocabulary: `loop_decide.StopReason` enumerates eleven
|
|
8
|
+
ways a loop can stop and **not one means "the work is finished"** — every terminal
|
|
9
|
+
path is a give-up (`ITERATION_CAP`), a circuit-break (`CONSECUTIVE_*`), an outage
|
|
10
|
+
(`RATE_LIMITED`/`LAUNCH_FAILED`), or a stall (`SPINNING`). `ITERATION_CAP` *is* the
|
|
11
|
+
"pass": the loop stops because it ran its rounds, and a human later runs
|
|
12
|
+
`dos resume` and discovers it was resumable the whole time. The fixpoint test
|
|
13
|
+
("is the residual empty?") already exists — it is just trapped in the
|
|
14
|
+
crash-recovery framing of `resume.py`, where it only runs when a run *died*.
|
|
15
|
+
|
|
16
|
+
This module lifts that fixpoint test out of the morgue and points it at a **live,
|
|
17
|
+
healthy** run: same `residual = declared − verified`, asked *forward* ("is it empty,
|
|
18
|
+
and may the loop stop?") instead of *backward* ("where do I re-enter?"). Completion
|
|
19
|
+
becomes the next distrust primitive — the verdict that refuses to take "✅ done" on
|
|
20
|
+
faith and adjudicates it against the fossils.
|
|
21
|
+
|
|
22
|
+
The self-report → distrust-verdict ladder (docs/117 §1.1), this is the missing rung:
|
|
23
|
+
|
|
24
|
+
"this step shipped" → verify() → SHIPPED / NOT_SHIPPED (oracle)
|
|
25
|
+
"I'm making progress" → liveness() → ADVANCING / SPINNING (liveness)
|
|
26
|
+
"I may take this region" → arbitrate() → ACQUIRE / refuse (arbiter)
|
|
27
|
+
"I crashed; resume from X" → resume_plan() → RESUMABLE / COMPLETE (resume)
|
|
28
|
+
"I'm done with the whole job"→ classify() → COMPLETE / INCOMPLETE ← THIS
|
|
29
|
+
|
|
30
|
+
Reuse, not reimplementation (docs/117 §5.1, §9)
|
|
31
|
+
===============================================
|
|
32
|
+
|
|
33
|
+
`classify` does **not** re-derive the residual. It calls `resume.resume_plan` —
|
|
34
|
+
which already does the ancestry re-adjudication, the contiguous-prefix rule, and
|
|
35
|
+
the fail-closed treatment of a `STEP_CLAIMED`-but-unverified step (that step stays
|
|
36
|
+
IN the residual, `resume.py:282`) — and then **maps the backward verdict forward**:
|
|
37
|
+
|
|
38
|
+
resume.COMPLETE → Completion.COMPLETE (residual empty: stop-on-done)
|
|
39
|
+
resume.RESUMABLE → Completion.INCOMPLETE (residual non-empty: re-dispatch IT)
|
|
40
|
+
resume.DIVERGED → Completion.INCOMPLETE (work remains; ground truth moved —
|
|
41
|
+
still not done; carries the residual)
|
|
42
|
+
resume.UNRESUMABLE → Completion.INDETERMINATE (unsound fold / no intent: refuse to
|
|
43
|
+
CALL it done, don't guess — the floor)
|
|
44
|
+
|
|
45
|
+
So every property `resume` proved — claimed-≠-verified, contiguous-prefix coverage,
|
|
46
|
+
the `STEP_VERIFIED`-re-adjudicated-at-read fix (docs/107 §5 / docs/103) — is
|
|
47
|
+
inherited here for free. The only thing `completion` adds is the *forward framing*
|
|
48
|
+
and the *convergence* verdict over rounds (below); the residual arithmetic is
|
|
49
|
+
`resume`'s, byte-for-byte.
|
|
50
|
+
|
|
51
|
+
What is NOT here yet (the later phases of docs/117)
|
|
52
|
+
===================================================
|
|
53
|
+
|
|
54
|
+
* **`UNDERDECLARED`** — the Gap-B refusal ("the residual is empty, but a
|
|
55
|
+
`ScopeSource` says the declared extent was smaller than the real job") is now
|
|
56
|
+
WIRED: `classify` takes `scope_verdicts` and folds them through
|
|
57
|
+
`scope_source.honest_under_floor` (docs/117 §5.3 / Phase 4) — the pluggable
|
|
58
|
+
extent rung, the `overlap_policy` shape, structurally able only to make
|
|
59
|
+
completion *harder*. With no verdicts supplied (the default) `classify` answers
|
|
60
|
+
from the declared steps alone — the honest floor, exactly as `resume` does — so
|
|
61
|
+
this is opt-in and byte-identical when unused. What is still future: a richer
|
|
62
|
+
set of *real* driver sources beyond the reference one, and the `dos complete
|
|
63
|
+
--scope-source` CLI / `dos.toml [completion] scope_sources` config seam that
|
|
64
|
+
populates `scope_verdicts` from a workspace declaration (today a caller passes
|
|
65
|
+
them explicitly; the kernel seam + one driver are the shipped part).
|
|
66
|
+
* **The loop-stop wiring** (`StopReason.COMPLETE`/`THRASHING`, residual
|
|
67
|
+
re-dispatch — docs/117 §5.4, Phase 3). This module ships the pure verdicts the
|
|
68
|
+
loop will read; it does not touch the running loop. Same staging as
|
|
69
|
+
`liveness` (the verdict shipped before the `loop_decide` consumer did).
|
|
70
|
+
|
|
71
|
+
Why a pure leaf with no I/O
|
|
72
|
+
===========================
|
|
73
|
+
|
|
74
|
+
The `liveness`/`resume` rule: `classify(evidence, policy) -> verdict` makes no
|
|
75
|
+
subprocess/file/clock call — all evidence (`LedgerState`, `AncestryFacts`, the
|
|
76
|
+
residual-size history) is gathered at the caller boundary (the same git read
|
|
77
|
+
`resume`'s `dos resume` path does) and handed in, so the verdict is replay-tested
|
|
78
|
+
on frozen fixtures. The verdict is **advisory** (docs/99): it mints the belief "the
|
|
79
|
+
declared work is verifiably closed" / "this loop will not converge"; the act of
|
|
80
|
+
*stopping* is the loop's, never the kernel's.
|
|
81
|
+
|
|
82
|
+
Pure stdlib — no third-party imports, no I/O. Imports one sibling kernel module
|
|
83
|
+
(`resume`), exactly as `resume` imports `intent_ledger` — the "no host, no I/O
|
|
84
|
+
policy" litmus, not "no sibling import" (CLAUDE.md).
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
from __future__ import annotations
|
|
88
|
+
|
|
89
|
+
import enum
|
|
90
|
+
from dataclasses import dataclass
|
|
91
|
+
from typing import Optional
|
|
92
|
+
|
|
93
|
+
from dos.intent_ledger import LedgerState
|
|
94
|
+
from dos import resume as _resume
|
|
95
|
+
from dos.resume import AncestryFacts, ResumePolicy, DEFAULT_POLICY as _RESUME_DEFAULT_POLICY
|
|
96
|
+
from dos.scope_source import ScopeVerdict, honest_under_floor
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
# ───────────────────────────── the live completion verdict ────────────────────
|
|
100
|
+
class Completion(str, enum.Enum):
|
|
101
|
+
"""The typed completion verdict — four states, mutually exclusive (docs/117 §5.1).
|
|
102
|
+
|
|
103
|
+
`str`-valued so it round-trips a `--json` token / exit-code map without a lookup
|
|
104
|
+
table (the `Resume` / `Liveness` / `gate_classify.Verdict` idiom). The asymmetry
|
|
105
|
+
is the point: only COMPLETE authorises the loop to stop-on-done; everything else
|
|
106
|
+
keeps the work open (INCOMPLETE re-dispatches; INDETERMINATE refuses to assert
|
|
107
|
+
done on an unsound fold).
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
COMPLETE = "COMPLETE" # residual empty — every declared step verified; the loop MAY stop-on-done
|
|
111
|
+
INCOMPLETE = "INCOMPLETE" # residual non-empty — verifiably more to do; re-dispatch the residual
|
|
112
|
+
UNDERDECLARED = "UNDERDECLARED" # residual empty BUT an external ScopeSource says the extent under-declared (Phase 4; not emitted yet)
|
|
113
|
+
INDETERMINATE = "INDETERMINATE" # unsound fold / no intent — refuse to CALL it done, don't guess (the floor)
|
|
114
|
+
|
|
115
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
116
|
+
return self.value
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def is_done(self) -> bool:
|
|
120
|
+
"""True iff the loop is authorised to stop because the work is finished."""
|
|
121
|
+
return self is Completion.COMPLETE
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def has_residual(self) -> bool:
|
|
125
|
+
"""True iff there is verifiably more declared work to do (INCOMPLETE only)."""
|
|
126
|
+
return self is Completion.INCOMPLETE
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(frozen=True)
|
|
130
|
+
class CompletionVerdict:
|
|
131
|
+
"""The single verdict `classify` returns, with the derivation echoed back.
|
|
132
|
+
|
|
133
|
+
`state` is the typed `Completion`. `reason` is the operator-facing one-liner.
|
|
134
|
+
`residual` is the ordered remaining step ids (empty iff COMPLETE) — the loop
|
|
135
|
+
re-dispatches THESE, not a fresh pass (docs/117 §5.4). `verified` is the
|
|
136
|
+
contiguous-verified prefix the COMPLETE/INCOMPLETE rests on. `declared` is the
|
|
137
|
+
full declared extent (so a reader sees the denominator). `run_id` keys it.
|
|
138
|
+
`to_dict` is the `--json` shape (the `ResumePlan.to_dict` idiom).
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
state: Completion
|
|
142
|
+
reason: str
|
|
143
|
+
run_id: str
|
|
144
|
+
residual: tuple[str, ...] = ()
|
|
145
|
+
verified: tuple[str, ...] = ()
|
|
146
|
+
declared: tuple[str, ...] = ()
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def fraction_done(self) -> Optional[float]:
|
|
150
|
+
"""|verified| / |declared| — the closure fraction, or None when nothing is
|
|
151
|
+
declared (a free-form goal has no step denominator). A legibility aid for the
|
|
152
|
+
surfaced line; never load-bearing for the verdict itself."""
|
|
153
|
+
n = len(self.declared)
|
|
154
|
+
return (len(self.verified) / n) if n else None
|
|
155
|
+
|
|
156
|
+
def to_dict(self) -> dict:
|
|
157
|
+
out = {
|
|
158
|
+
"state": self.state.value,
|
|
159
|
+
"reason": self.reason,
|
|
160
|
+
"run_id": self.run_id,
|
|
161
|
+
"residual": list(self.residual),
|
|
162
|
+
"verified": list(self.verified),
|
|
163
|
+
"declared": list(self.declared),
|
|
164
|
+
"is_done": self.state.is_done,
|
|
165
|
+
}
|
|
166
|
+
frac = self.fraction_done
|
|
167
|
+
if frac is not None:
|
|
168
|
+
out["fraction_done"] = round(frac, 4)
|
|
169
|
+
return out
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def classify(
|
|
173
|
+
state: LedgerState,
|
|
174
|
+
ancestry: AncestryFacts,
|
|
175
|
+
policy: ResumePolicy = _RESUME_DEFAULT_POLICY,
|
|
176
|
+
scope_verdicts: tuple[ScopeVerdict, ...] = (),
|
|
177
|
+
) -> CompletionVerdict:
|
|
178
|
+
"""Adjudicate whether the WHOLE declared job is verifiably done. PURE — no I/O.
|
|
179
|
+
|
|
180
|
+
Reuses `resume.resume_plan`'s residual arithmetic verbatim (docs/117 §5.1) and
|
|
181
|
+
maps its backward (where-do-I-re-enter) verdict to a forward (may-I-stop) one:
|
|
182
|
+
|
|
183
|
+
* `resume.COMPLETE` → `COMPLETE` — residual empty; every declared step
|
|
184
|
+
verified on the non-forgeable rung.
|
|
185
|
+
* `resume.RESUMABLE` → `INCOMPLETE` — a non-empty residual remains; the loop
|
|
186
|
+
re-dispatches it (carried on `.residual`).
|
|
187
|
+
* `resume.DIVERGED` → `INCOMPLETE` — work remains AND ground truth moved past
|
|
188
|
+
the resume point. Still not done; the
|
|
189
|
+
residual is carried so the loop/operator
|
|
190
|
+
can reconcile (the divergence is in the
|
|
191
|
+
reason, but the completion answer is the
|
|
192
|
+
same "no, not done").
|
|
193
|
+
* `resume.UNRESUMABLE` → `INDETERMINATE`— no INTENT, a corrupt fold, or a schema
|
|
194
|
+
this kernel is too old to read: refuse to
|
|
195
|
+
CALL it done (the floor — never assert
|
|
196
|
+
completion on an unsound fold).
|
|
197
|
+
|
|
198
|
+
The verdict is **advisory** (docs/99): it mints "done / not done / can't tell" and
|
|
199
|
+
the loop *decides* to stop on COMPLETE; the kernel never re-runs the work (docs/117
|
|
200
|
+
§8).
|
|
201
|
+
|
|
202
|
+
The `scope` rung (docs/117 Phase 4) distrusts the residual's DENOMINATOR. When
|
|
203
|
+
`resume` says the residual is empty, `classify` does not grant `COMPLETE`
|
|
204
|
+
unconditionally — it first folds the caller-supplied `scope_verdicts` through
|
|
205
|
+
`scope_source.honest_under_floor`: `COMPLETE` requires the residual empty AND
|
|
206
|
+
every scope source agreeing the declared extent was the whole job. If any source
|
|
207
|
+
voted the extent under-declared, `classify` emits `UNDERDECLARED` instead (the
|
|
208
|
+
residual is empty, but the *scope* the residual was measured against was too
|
|
209
|
+
small — `docs/103` inward, on the denominator). With no `scope_verdicts` (the
|
|
210
|
+
default `()`), `honest_under_floor(())` is honest, so completion is **exactly
|
|
211
|
+
today's "all declared verified" floor** and `UNDERDECLARED` is never emitted — the
|
|
212
|
+
Phase-1 behavior, byte-for-byte. The sources are gathered + run (`run_scope`,
|
|
213
|
+
fail-to-strict) at the caller boundary and handed in, exactly as `AncestryFacts`
|
|
214
|
+
is — the verdict stays pure and replay-testable.
|
|
215
|
+
"""
|
|
216
|
+
plan = _resume.resume_plan(state, ancestry, policy)
|
|
217
|
+
declared = tuple(state.declared_steps)
|
|
218
|
+
rid = plan.run_id
|
|
219
|
+
|
|
220
|
+
if plan.verdict is _resume.Resume.COMPLETE:
|
|
221
|
+
# The residual is empty. Before calling it DONE, distrust the denominator:
|
|
222
|
+
# fold the scope verdicts. With no sources wired this is honest (today's
|
|
223
|
+
# floor); any source flagging under-declaration flips it to UNDERDECLARED.
|
|
224
|
+
scope = honest_under_floor(tuple(scope_verdicts))
|
|
225
|
+
n = len(plan.verified) or len(declared)
|
|
226
|
+
if not scope.extent_honest:
|
|
227
|
+
return CompletionVerdict(
|
|
228
|
+
state=Completion.UNDERDECLARED,
|
|
229
|
+
reason=(
|
|
230
|
+
f"all {n} declared unit(s) verified, BUT the declared extent is "
|
|
231
|
+
f"not the whole job — {scope.reason}; not done (a human must "
|
|
232
|
+
f"reconcile the scope before it can close)"
|
|
233
|
+
),
|
|
234
|
+
run_id=rid,
|
|
235
|
+
residual=(),
|
|
236
|
+
verified=plan.verified,
|
|
237
|
+
declared=declared,
|
|
238
|
+
)
|
|
239
|
+
return CompletionVerdict(
|
|
240
|
+
state=Completion.COMPLETE,
|
|
241
|
+
reason=(
|
|
242
|
+
f"all {n} declared unit(s) verified against ancestry — the residual is "
|
|
243
|
+
f"empty; the declared job is done (stop-on-done, not out-of-budget)"
|
|
244
|
+
),
|
|
245
|
+
run_id=rid,
|
|
246
|
+
residual=(),
|
|
247
|
+
verified=plan.verified,
|
|
248
|
+
declared=declared,
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
if plan.verdict is _resume.Resume.UNRESUMABLE:
|
|
252
|
+
# The fold is unsound (no INTENT / corrupt / too-new schema). We cannot
|
|
253
|
+
# ground a residual, so we cannot soundly say "done" OR "this much remains".
|
|
254
|
+
# Refuse to assert completion — the `resume.UNRESUMABLE` floor, restated.
|
|
255
|
+
return CompletionVerdict(
|
|
256
|
+
state=Completion.INDETERMINATE,
|
|
257
|
+
reason=(
|
|
258
|
+
f"cannot adjudicate completion — {plan.reason} "
|
|
259
|
+
f"(refusing to call a job done from an unsound ledger fold)"
|
|
260
|
+
),
|
|
261
|
+
run_id=rid,
|
|
262
|
+
residual=plan.residual,
|
|
263
|
+
verified=plan.verified,
|
|
264
|
+
declared=declared,
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
# RESUMABLE or DIVERGED — both mean "verifiably more to do". The completion
|
|
268
|
+
# answer is the same INCOMPLETE; the residual is carried so the loop re-dispatches
|
|
269
|
+
# exactly the unfinished units (docs/117 §5.4 step 3), and the reason preserves
|
|
270
|
+
# the divergence note when resume flagged it (so the operator still sees it).
|
|
271
|
+
n_resid = len(plan.residual)
|
|
272
|
+
n_decl = len(declared) or n_resid
|
|
273
|
+
if plan.verdict is _resume.Resume.DIVERGED:
|
|
274
|
+
reason = (
|
|
275
|
+
f"INCOMPLETE — {n_resid} of {n_decl} declared unit(s) unverified, AND "
|
|
276
|
+
f"ground truth advanced past the resume point ({plan.reason}); not done — "
|
|
277
|
+
f"the residual must be reconciled before it can close"
|
|
278
|
+
)
|
|
279
|
+
else:
|
|
280
|
+
reason = (
|
|
281
|
+
f"INCOMPLETE — {len(plan.verified)}/{n_decl} declared unit(s) verified; "
|
|
282
|
+
f"{n_resid} remain in the residual ({plan.reason})"
|
|
283
|
+
)
|
|
284
|
+
return CompletionVerdict(
|
|
285
|
+
state=Completion.INCOMPLETE,
|
|
286
|
+
reason=reason,
|
|
287
|
+
run_id=rid,
|
|
288
|
+
residual=plan.residual,
|
|
289
|
+
verified=plan.verified,
|
|
290
|
+
declared=declared,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ───────────────────────────── the convergence verdict ────────────────────────
|
|
295
|
+
# docs/117 §5.2 / Gap C. COMPLETE is a STATIC fixpoint (residual empty *now*). The
|
|
296
|
+
# "can't stop" failure is DYNAMIC: the residual never empties because each round adds
|
|
297
|
+
# as much as it closes (the reviewer-finds-new-findings loop). This verdict is over a
|
|
298
|
+
# HISTORY of residual sizes — one int per completed round — and answers "is |residual|
|
|
299
|
+
# actually shrinking, or is the loop busy-but-forever?".
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
class Convergence(str, enum.Enum):
|
|
303
|
+
"""Is the residual trending to empty, or oscillating/growing forever? (docs/117 §5.2).
|
|
304
|
+
|
|
305
|
+
A DIFFERENT "no" from the two we already have:
|
|
306
|
+
* `liveness.SPINNING` = not committing at all (zero forward git delta) — temporal.
|
|
307
|
+
* `resume.RESUMABLE` = work remains (residual non-empty) — a single snapshot.
|
|
308
|
+
* `THRASHING` (here) = commits ARE landing, the residual IS changing, but it is
|
|
309
|
+
not monotonically decreasing — the loop is productive and
|
|
310
|
+
will run forever. The honest verdict for "no fixpoint".
|
|
311
|
+
"""
|
|
312
|
+
|
|
313
|
+
CONVERGING = "CONVERGING" # |residual| (weakly) decreasing toward 0 — keep going
|
|
314
|
+
THRASHING = "THRASHING" # |residual| failed to decrease for max_nonconverging rounds — surface, don't burn budget
|
|
315
|
+
STARVED = "STARVED" # |residual| non-empty and UNCHANGED across the window — distinct from THRASHING's churn
|
|
316
|
+
INSUFFICIENT = "INSUFFICIENT" # too few rounds to judge a trend yet — keep going (no verdict)
|
|
317
|
+
|
|
318
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
319
|
+
return self.value
|
|
320
|
+
|
|
321
|
+
@property
|
|
322
|
+
def should_surface(self) -> bool:
|
|
323
|
+
"""True iff a loop should STOP-and-surface rather than continue (the no-fixpoint set)."""
|
|
324
|
+
return self in (Convergence.THRASHING, Convergence.STARVED)
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@dataclass(frozen=True)
|
|
328
|
+
class ConvergencePolicy:
|
|
329
|
+
"""Knobs for the convergence verdict — policy, not mechanism (the `ResumePolicy` split).
|
|
330
|
+
|
|
331
|
+
* ``max_nonconverging`` — how many consecutive rounds |residual| may fail to
|
|
332
|
+
strictly decrease before THRASHING. Default 3 — the existing circuit-breaker
|
|
333
|
+
idiom (`loop_decide`'s `max_unclear` / `max_dirty_zero`).
|
|
334
|
+
* ``window`` — how many of the most-recent rounds the trend is judged over.
|
|
335
|
+
Default 4. Fewer than 2 rounds is always INSUFFICIENT (no trend to read).
|
|
336
|
+
|
|
337
|
+
Defaults are GENERIC (no host tuning); a workspace could declare its own in
|
|
338
|
+
`dos.toml [completion]` (a future seam, like the planned `[liveness]`/`[resume]`).
|
|
339
|
+
"""
|
|
340
|
+
|
|
341
|
+
max_nonconverging: int = 3
|
|
342
|
+
window: int = 4
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
DEFAULT_CONVERGENCE_POLICY = ConvergencePolicy()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
@dataclass(frozen=True)
|
|
349
|
+
class ConvergenceVerdict:
|
|
350
|
+
"""The typed convergence verdict + the derivation (the window it judged)."""
|
|
351
|
+
|
|
352
|
+
state: Convergence
|
|
353
|
+
reason: str
|
|
354
|
+
window: tuple[int, ...] = () # the residual sizes the verdict was read over (most recent last)
|
|
355
|
+
|
|
356
|
+
def to_dict(self) -> dict:
|
|
357
|
+
return {
|
|
358
|
+
"state": self.state.value,
|
|
359
|
+
"reason": self.reason,
|
|
360
|
+
"window": list(self.window),
|
|
361
|
+
"should_surface": self.state.should_surface,
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def convergence(
|
|
366
|
+
residual_history: tuple[int, ...],
|
|
367
|
+
policy: ConvergencePolicy = DEFAULT_CONVERGENCE_POLICY,
|
|
368
|
+
) -> ConvergenceVerdict:
|
|
369
|
+
"""Read the residual-size trend across rounds. PURE — over a history of ints.
|
|
370
|
+
|
|
371
|
+
One int per completed round (the loop appends ``|residual|`` each iteration; the
|
|
372
|
+
history is cheap and lives in `LoopState`). The verdict (docs/117 §5.2):
|
|
373
|
+
|
|
374
|
+
* `CONVERGING` — within the window, |residual| is weakly decreasing and the
|
|
375
|
+
latest is below the window's first (it is trending to 0). Keep going.
|
|
376
|
+
* `STARVED` — the window is non-empty, > 0, and FLAT (every value equal):
|
|
377
|
+
no progress at all, distinct from THRASHING's churn.
|
|
378
|
+
* `THRASHING` — |residual| failed to STRICTLY decrease for the last
|
|
379
|
+
``max_nonconverging`` rounds (it oscillated or grew): a productive loop with
|
|
380
|
+
no fixpoint — surface a decision, don't burn the cap silently.
|
|
381
|
+
* `INSUFFICIENT` — fewer than 2 rounds (or fewer than 2 in the window): no
|
|
382
|
+
trend to read yet; the loop continues (this is never a stop signal).
|
|
383
|
+
|
|
384
|
+
A residual that reaches 0 is CONVERGING (it converged) regardless of the path —
|
|
385
|
+
the static `COMPLETE` from `classify` is the authority on done-ness; this verdict
|
|
386
|
+
only catches the *won't-ever-get-there* case.
|
|
387
|
+
"""
|
|
388
|
+
hist = tuple(int(x) for x in residual_history)
|
|
389
|
+
if len(hist) < 2:
|
|
390
|
+
return ConvergenceVerdict(
|
|
391
|
+
state=Convergence.INSUFFICIENT,
|
|
392
|
+
reason=(f"only {len(hist)} round(s) recorded — need ≥2 to read a trend; "
|
|
393
|
+
f"continue (no convergence verdict yet)"),
|
|
394
|
+
window=hist,
|
|
395
|
+
)
|
|
396
|
+
|
|
397
|
+
w = hist[-policy.window:] if policy.window > 0 else hist
|
|
398
|
+
first, last = w[0], w[-1]
|
|
399
|
+
|
|
400
|
+
# Converged (or converging to) empty — the happy path. A 0 anywhere recent means
|
|
401
|
+
# the static COMPLETE verdict will fire; never call that THRASHING.
|
|
402
|
+
if last == 0:
|
|
403
|
+
return ConvergenceVerdict(
|
|
404
|
+
state=Convergence.CONVERGING,
|
|
405
|
+
reason=f"residual reached 0 over {w} — converged",
|
|
406
|
+
window=w,
|
|
407
|
+
)
|
|
408
|
+
|
|
409
|
+
# Flat and non-empty across the whole window → STARVED (no churn, no progress).
|
|
410
|
+
if len(set(w)) == 1:
|
|
411
|
+
return ConvergenceVerdict(
|
|
412
|
+
state=Convergence.STARVED,
|
|
413
|
+
reason=(f"residual is unchanged at {last} across {len(w)} round(s) {w} — "
|
|
414
|
+
f"no progress; a precondition is likely blocking (surface)"),
|
|
415
|
+
window=w,
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
# THRASHING test — the residual CHURNS UPWARD without reaching a new low.
|
|
419
|
+
#
|
|
420
|
+
# The defining feature of a no-fixpoint loop is that the residual *bounces back
|
|
421
|
+
# up*: each pass closes some work and opens as much (the reviewer-finds-new-
|
|
422
|
+
# findings loop). The honest signal is therefore (a) an UP-step happened in the
|
|
423
|
+
# recent window — the residual grew at least once — AND (b) the latest value is
|
|
424
|
+
# NOT a new low for that window (it didn't end by breaking through its prior
|
|
425
|
+
# floor). Together: it went up and didn't recover, so it is going nowhere.
|
|
426
|
+
#
|
|
427
|
+
# This is the criterion a per-transition or endpoint test both get wrong:
|
|
428
|
+
# (4,3,4,3) — up-step 3→4 present, last 3 == window min 3 (not a NEW low) → THRASHING
|
|
429
|
+
# (1,2,3,4) — up-steps present, last 4 is the max (not a low) → THRASHING
|
|
430
|
+
# (8,5,3,1) — no up-step at all → CONVERGING
|
|
431
|
+
# We require k+1 rounds of history before trusting it, so one stray uptick inside
|
|
432
|
+
# an otherwise-improving run does not trip a stop (the decision must be confident).
|
|
433
|
+
k = policy.max_nonconverging
|
|
434
|
+
recent = hist[-(k + 1):]
|
|
435
|
+
if len(recent) >= k + 1:
|
|
436
|
+
went_up = any(recent[i + 1] > recent[i] for i in range(len(recent) - 1))
|
|
437
|
+
earlier_min = min(recent[:-1])
|
|
438
|
+
no_new_low = last >= earlier_min
|
|
439
|
+
if went_up and no_new_low:
|
|
440
|
+
return ConvergenceVerdict(
|
|
441
|
+
state=Convergence.THRASHING,
|
|
442
|
+
reason=(f"residual churned without reaching a new low over {k} round(s) "
|
|
443
|
+
f"{recent} (latest {last} ≥ window floor {earlier_min}) — the "
|
|
444
|
+
f"loop is productive but has no fixpoint; cut scope or accept "
|
|
445
|
+
f"partial (surface, don't burn the cap)"),
|
|
446
|
+
window=w,
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
# Net-decreasing across the window (below where it began) → CONVERGING.
|
|
450
|
+
if last < first:
|
|
451
|
+
return ConvergenceVerdict(
|
|
452
|
+
state=Convergence.CONVERGING,
|
|
453
|
+
reason=f"residual decreasing across {w} ({first} → {last}) — fixpoint reachable",
|
|
454
|
+
window=w,
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
# Stuck-but-young: not net-decreasing, but fewer than k+1 rounds of history — not
|
|
458
|
+
# confident enough to call THRASHING. Continue; the loop confirms or clears the
|
|
459
|
+
# trend as more rounds land. (CONVERGING here means "no stop signal yet," not
|
|
460
|
+
# "provably shrinking" — the reason says so.)
|
|
461
|
+
return ConvergenceVerdict(
|
|
462
|
+
state=Convergence.CONVERGING,
|
|
463
|
+
reason=(f"residual {w}: not yet net-decreasing but under the "
|
|
464
|
+
f"{policy.max_nonconverging}-round non-progress threshold — continue"),
|
|
465
|
+
window=w,
|
|
466
|
+
)
|
dos/concurrency_class.py
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Concurrency-class budgets as declared data — the operator surface over the
|
|
2
|
+
already-shipped arbiter class-budget enforcement (docs/97 Phase 1-2, C13).
|
|
3
|
+
|
|
4
|
+
The arbiter ALREADY enforces "at most N of kind K may hold a lease at once":
|
|
5
|
+
`arbiter.arbitrate(..., class_budgets={"priority": 3})` counts live leases per
|
|
6
|
+
kind on the auto-pick walk, skips budget-exhausted candidates, and returns the
|
|
7
|
+
named `CLASS_BUDGET_EXHAUSTED` refuse (`arbiter.py:356,366,714`). What was missing
|
|
8
|
+
is the *operator surface* — the budgets were reachable only as a Python parameter.
|
|
9
|
+
This module is that surface's data half: a closed `ConcurrencyClass{name,
|
|
10
|
+
max_concurrent}` dataclass + a `from_table` reader for the `[[concurrency_class]]`
|
|
11
|
+
array-of-tables in `dos.toml`, projecting to the exact `{kind: N}` dict the arbiter
|
|
12
|
+
consumes.
|
|
13
|
+
|
|
14
|
+
This is mechanism-as-data, the `reasons`/`stamp`/`lanes` seam pattern: the kernel
|
|
15
|
+
ships the enforcement; the host declares the VALUES per workspace. It names no host
|
|
16
|
+
class — `"priority"`, `"apply"`, whatever — those are workspace data, so Law 1
|
|
17
|
+
(kernel imports no host) holds. It deliberately carries ONLY a max-concurrent
|
|
18
|
+
budget; it does NOT carry lane priority/value ordering — the arbiter refuses to
|
|
19
|
+
hard-code "whose work is valuable" (docs/90 §6), so that stays host policy and
|
|
20
|
+
never enters this registry.
|
|
21
|
+
|
|
22
|
+
[[concurrency_class]]
|
|
23
|
+
name = "priority"
|
|
24
|
+
max_concurrent = 3
|
|
25
|
+
|
|
26
|
+
[[concurrency_class]]
|
|
27
|
+
name = "apply"
|
|
28
|
+
max_concurrent = 1
|
|
29
|
+
|
|
30
|
+
Pure stdlib leaf — the closed-enum-as-data discipline, validated loud-on-malformed
|
|
31
|
+
(a host that mis-declared a budget wants it surfaced at load, not silently dropped
|
|
32
|
+
to "no budget" which would let the class run unbounded).
|
|
33
|
+
"""
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from dataclasses import dataclass
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ConcurrencyClass:
|
|
41
|
+
"""One declared budget: at most `max_concurrent` leases of kind `name` at once.
|
|
42
|
+
|
|
43
|
+
`name` is the lane-KIND the arbiter keys budgets on (`lease["lane_kind"]`),
|
|
44
|
+
opaque workspace data. `max_concurrent` is a non-negative int — 0 means "admit
|
|
45
|
+
none of this kind" (a valid, if drastic, throttle); a negative value is a
|
|
46
|
+
declaration error.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
name: str
|
|
50
|
+
max_concurrent: int
|
|
51
|
+
|
|
52
|
+
def __post_init__(self) -> None:
|
|
53
|
+
if not self.name:
|
|
54
|
+
raise ValueError("concurrency_class.name is required (the lane kind)")
|
|
55
|
+
if not isinstance(self.max_concurrent, int) or isinstance(self.max_concurrent, bool):
|
|
56
|
+
raise ValueError(
|
|
57
|
+
f"concurrency_class[{self.name!r}].max_concurrent must be an int, "
|
|
58
|
+
f"got {type(self.max_concurrent).__name__}"
|
|
59
|
+
)
|
|
60
|
+
if self.max_concurrent < 0:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"concurrency_class[{self.name!r}].max_concurrent must be ≥ 0, "
|
|
63
|
+
f"got {self.max_concurrent}"
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class ClassBudgets:
|
|
69
|
+
"""The declared concurrency-class registry — an ordered set of `ConcurrencyClass`.
|
|
70
|
+
|
|
71
|
+
Carries the budgets as data and projects them to the `{kind: max_concurrent}`
|
|
72
|
+
dict `arbiter.arbitrate(class_budgets=...)` already consumes. Empty by default
|
|
73
|
+
(no file / no `[[concurrency_class]]` table → no budgets → today's unbounded-
|
|
74
|
+
per-kind behavior, the additive-degradation floor)."""
|
|
75
|
+
|
|
76
|
+
classes: tuple[ConcurrencyClass, ...] = ()
|
|
77
|
+
|
|
78
|
+
def as_arbiter_budgets(self) -> dict[str, int]:
|
|
79
|
+
"""The `{kind: max_concurrent}` dict the arbiter takes. A duplicate name is a
|
|
80
|
+
last-wins override (the host declared the same class twice — honor the last,
|
|
81
|
+
the toml array's natural order)."""
|
|
82
|
+
out: dict[str, int] = {}
|
|
83
|
+
for c in self.classes:
|
|
84
|
+
out[c.name] = c.max_concurrent
|
|
85
|
+
return out
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def from_table(cls, table: object) -> "ClassBudgets":
|
|
89
|
+
"""Build from a parsed `[[concurrency_class]]` array-of-tables.
|
|
90
|
+
|
|
91
|
+
TOML's `[[concurrency_class]]` parses to a LIST of dicts. Tolerant of an
|
|
92
|
+
absent/empty list (→ no budgets). Rejects, with a `ValueError` naming the
|
|
93
|
+
offending entry, anything that is not a `{name, max_concurrent}` table —
|
|
94
|
+
loud-on-malformed, the sibling-seam discipline. Mirrors
|
|
95
|
+
`reason_morphology.MorphologyRuleset.from_table` in shape (the array-of-
|
|
96
|
+
tables reader)."""
|
|
97
|
+
if table is None:
|
|
98
|
+
return cls(())
|
|
99
|
+
if not isinstance(table, (list, tuple)):
|
|
100
|
+
raise ValueError(
|
|
101
|
+
f"[[concurrency_class]] must be an array of tables, "
|
|
102
|
+
f"got {type(table).__name__}"
|
|
103
|
+
)
|
|
104
|
+
out: list[ConcurrencyClass] = []
|
|
105
|
+
for i, item in enumerate(table):
|
|
106
|
+
if not isinstance(item, dict):
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"[[concurrency_class]] entry {i} must be a table "
|
|
109
|
+
f"({{name, max_concurrent}}), got {type(item).__name__}"
|
|
110
|
+
)
|
|
111
|
+
if "name" not in item or "max_concurrent" not in item:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
f"[[concurrency_class]] entry {i} needs both `name` and "
|
|
114
|
+
f"`max_concurrent` (got keys {sorted(item)})"
|
|
115
|
+
)
|
|
116
|
+
# ConcurrencyClass.__post_init__ validates the value shapes (name
|
|
117
|
+
# non-empty, max_concurrent a non-negative int).
|
|
118
|
+
out.append(ConcurrencyClass(
|
|
119
|
+
name=str(item["name"]), max_concurrent=item["max_concurrent"]))
|
|
120
|
+
return cls(tuple(out))
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
# An empty registry — the kernel default (no per-kind budget, today's behavior).
|
|
124
|
+
NO_CLASS_BUDGETS = ClassBudgets(())
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def parse_cli_budgets(pairs: list[str] | None) -> dict[str, int]:
|
|
128
|
+
"""Parse repeatable `--class-budget KIND=N` operator flags into `{kind: N}`.
|
|
129
|
+
|
|
130
|
+
Each `pairs` item is a `"KIND=N"` string. Raises `ValueError` (operator error,
|
|
131
|
+
the CLI maps it to a clean contract-error exit, never a traceback) on a malformed
|
|
132
|
+
pair: no `=`, an empty kind, or a non-int / negative N. An empty/None list → {}.
|
|
133
|
+
These OVERLAY the config-declared budgets at the call boundary (a `--class-budget`
|
|
134
|
+
wins over a `[[concurrency_class]]` of the same name — the explicit operator flag
|
|
135
|
+
beats the declared default)."""
|
|
136
|
+
out: dict[str, int] = {}
|
|
137
|
+
for raw in pairs or ():
|
|
138
|
+
if "=" not in raw:
|
|
139
|
+
raise ValueError(
|
|
140
|
+
f"--class-budget must be KIND=N, got {raw!r} (no '=')")
|
|
141
|
+
kind, _, val = raw.partition("=")
|
|
142
|
+
kind = kind.strip()
|
|
143
|
+
if not kind:
|
|
144
|
+
raise ValueError(f"--class-budget {raw!r} has an empty KIND")
|
|
145
|
+
try:
|
|
146
|
+
n = int(val.strip())
|
|
147
|
+
except ValueError:
|
|
148
|
+
raise ValueError(
|
|
149
|
+
f"--class-budget {raw!r}: N must be an integer, got {val.strip()!r}"
|
|
150
|
+
) from None
|
|
151
|
+
if n < 0:
|
|
152
|
+
raise ValueError(f"--class-budget {raw!r}: N must be ≥ 0, got {n}")
|
|
153
|
+
out[kind] = n
|
|
154
|
+
return out
|