echoact 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.
Files changed (80) hide show
  1. echoact/__init__.py +3 -0
  2. echoact/__main__.py +117 -0
  3. echoact/app.py +315 -0
  4. echoact/audio/__init__.py +0 -0
  5. echoact/audio/devices.py +192 -0
  6. echoact/audio/player.py +611 -0
  7. echoact/audio/wav.py +854 -0
  8. echoact/config/__init__.py +0 -0
  9. echoact/config/budget.py +370 -0
  10. echoact/config/settings.py +1244 -0
  11. echoact/db/__init__.py +0 -0
  12. echoact/db/backup.py +2429 -0
  13. echoact/db/migrations.py +434 -0
  14. echoact/db/schema.sql +214 -0
  15. echoact/db/store.py +2062 -0
  16. echoact/diagnostics.py +902 -0
  17. echoact/domain.py +487 -0
  18. echoact/engine/__init__.py +0 -0
  19. echoact/engine/container.py +843 -0
  20. echoact/engine/protocol.py +241 -0
  21. echoact/engine/runtime.py +324 -0
  22. echoact/engine/supervisor.py +961 -0
  23. echoact/engine/worker.py +659 -0
  24. echoact/errors.py +281 -0
  25. echoact/instance.py +172 -0
  26. echoact/jobs/__init__.py +0 -0
  27. echoact/jobs/engine.py +776 -0
  28. echoact/jobs/request.py +300 -0
  29. echoact/mcp/__init__.py +0 -0
  30. echoact/mcp/__main__.py +50 -0
  31. echoact/mcp/client.py +202 -0
  32. echoact/mcp/config.py +112 -0
  33. echoact/mcp/server.py +340 -0
  34. echoact/models/__init__.py +0 -0
  35. echoact/models/catalog.py +273 -0
  36. echoact/models/manifest.py +278 -0
  37. echoact/models/registry.py +1551 -0
  38. echoact/paths.py +93 -0
  39. echoact/policy.py +189 -0
  40. echoact/security/__init__.py +0 -0
  41. echoact/security/credentials.py +930 -0
  42. echoact/security/ratelimit.py +534 -0
  43. echoact/service/__init__.py +20 -0
  44. echoact/service/app.py +182 -0
  45. echoact/service/deps.py +563 -0
  46. echoact/service/errors.py +241 -0
  47. echoact/service/routes.py +1125 -0
  48. echoact/service/schemas.py +509 -0
  49. echoact/service/server.py +270 -0
  50. echoact/text/__init__.py +0 -0
  51. echoact/text/language.py +44 -0
  52. echoact/text/loader.py +577 -0
  53. echoact/text/normalize.py +924 -0
  54. echoact/text/segment.py +499 -0
  55. echoact/text/sniff.py +1202 -0
  56. echoact/ui/__init__.py +0 -0
  57. echoact/ui/bridge.py +50 -0
  58. echoact/ui/controls.py +360 -0
  59. echoact/ui/credential_dialog.py +131 -0
  60. echoact/ui/fonts.py +94 -0
  61. echoact/ui/i18n.py +260 -0
  62. echoact/ui/icons.py +440 -0
  63. echoact/ui/library.py +1642 -0
  64. echoact/ui/licence.py +162 -0
  65. echoact/ui/main_window.py +1202 -0
  66. echoact/ui/mcp_setup.py +494 -0
  67. echoact/ui/models_view.py +1142 -0
  68. echoact/ui/notifications.py +202 -0
  69. echoact/ui/reading.py +494 -0
  70. echoact/ui/settings_view.py +2258 -0
  71. echoact/ui/status_view.py +1193 -0
  72. echoact/ui/theme.py +579 -0
  73. echoact/util/__init__.py +0 -0
  74. echoact/util/ids.py +62 -0
  75. echoact/util/logging.py +127 -0
  76. echoact-0.1.0.dist-info/METADATA +162 -0
  77. echoact-0.1.0.dist-info/RECORD +80 -0
  78. echoact-0.1.0.dist-info/WHEEL +4 -0
  79. echoact-0.1.0.dist-info/entry_points.txt +3 -0
  80. echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
File without changes
@@ -0,0 +1,370 @@
1
+ """Turning a resource *setting* into the ceiling a job actually runs under.
2
+
3
+ F-21 says the memory default is derived from the machine and that "the value
4
+ actually applied may be lower depending on currently available memory", so the
5
+ configured number and the enforced number are two different things. F-78 makes
6
+ that distinction visible to the user; this module is where it is computed, and
7
+ ``Settings`` never stores the result -- a derived number written back into a
8
+ settings file would follow the user onto a machine it does not describe.
9
+
10
+ F-23 draws the other line: below a 2 GiB budget the model is not loaded at all.
11
+ That is a refusal to start, not a degraded run, so ``resolve_budget`` raises
12
+ rather than returning something unusable.
13
+
14
+ Sampling and the halt decision live here too, because they compare against the
15
+ same numbers. N-04 fixes the headroom and the roughly half-second cadence,
16
+ N-21 requires the generation job to be measured apart from the whole app --
17
+ which is why the sampler is pointed at the worker process's pid rather than at
18
+ this one -- and F-22 displays what it reports.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from collections.abc import Callable, Mapping
24
+ from dataclasses import dataclass, field
25
+ from enum import StrEnum
26
+ from typing import Any, Final
27
+
28
+ import psutil
29
+
30
+ from ..domain import Budget
31
+ from ..errors import Code, EchoActError
32
+ from ..policy import (
33
+ CPU_PERCENT_MAX,
34
+ CPU_PERCENT_MIN,
35
+ HEADROOM_FRACTION,
36
+ HEADROOM_MIN_BYTES,
37
+ MEMORY_CEILING_BYTES,
38
+ MEMORY_CEILING_FRACTION_OF_TOTAL,
39
+ MEMORY_DEFAULT_FRACTION,
40
+ MEMORY_DEFAULT_MAX_BYTES,
41
+ MEMORY_DEFAULT_MIN_BYTES,
42
+ MEMORY_FLOOR_BYTES,
43
+ RESOURCE_SAMPLE_INTERVAL_S,
44
+ )
45
+ from ..util.ids import monotonic
46
+ from .settings import Settings
47
+
48
+ #: Upper bound on worker threads, whatever the CPU budget works out to.
49
+ #:
50
+ #: A.5 measured twenty threads running about twice as slowly as two on this
51
+ #: engine -- classic oversubscription -- and tuned the product at the two the
52
+ #: Section 8.2 baseline produces (8 logical CPUs at F-20's 20% default). The
53
+ #: crossover point between "helps" and "hurts" was not measured, so the cap is
54
+ #: set at double the known-good figure and five times below the known-bad one.
55
+ #: Without a cap, F-20's 70% maximum on a 32-CPU workstation would ask for 22
56
+ #: threads and land squarely in the slow region, so the cap is what keeps
57
+ #: F-87's "threads come from the budget" from meaning "threads get worse as the
58
+ #: budget gets bigger". It is a measured tuning constant, not a policy limit
59
+ #: from Section 4, which is why it lives beside the code that applies it.
60
+ MAX_INTRA_OP_THREADS: Final = 4
61
+
62
+ #: How soon a caller refused for want of memory could get a different answer:
63
+ #: one monitoring interval, since nothing else re-examines the machine. F-57
64
+ #: requires a retryable refusal to carry a hint, and INSUFFICIENT_RESOURCES is
65
+ #: retryable because freeing memory elsewhere genuinely fixes it.
66
+ RESOURCE_RETRY_AFTER_S: Final = RESOURCE_SAMPLE_INTERVAL_S
67
+
68
+
69
+ def default_memory_bytes(total_ram_bytes: int) -> int:
70
+ """F-21's out-of-the-box budget: about 25% of total RAM, held to 2-6 GiB."""
71
+ quarter = int(total_ram_bytes * MEMORY_DEFAULT_FRACTION)
72
+ return max(MEMORY_DEFAULT_MIN_BYTES, min(MEMORY_DEFAULT_MAX_BYTES, quarter))
73
+
74
+
75
+ def memory_ceiling_bytes(total_ram_bytes: int) -> int:
76
+ """F-21's ceiling on what the owner may configure: at most 32 GiB, and
77
+ never more than half of total RAM."""
78
+ return int(min(MEMORY_CEILING_BYTES, total_ram_bytes * MEMORY_CEILING_FRACTION_OF_TOTAL))
79
+
80
+
81
+ def system_headroom_bytes(total_ram_bytes: int) -> int:
82
+ """N-04: the greater of 10% of total RAM or 1 GiB, kept free for the host."""
83
+ return int(max(total_ram_bytes * HEADROOM_FRACTION, HEADROOM_MIN_BYTES))
84
+
85
+
86
+ def intra_op_threads(logical_cpus: int, cpu_percent: int) -> int:
87
+ """F-87: the worker's thread count comes from F-20's budget, never from the
88
+ runtime's default.
89
+
90
+ Rounded half-up rather than with Python's banker's rounding, so a machine
91
+ that works out to exactly n.5 threads gets the larger count consistently
92
+ instead of alternating with the parity of n.
93
+ """
94
+ share = logical_cpus * cpu_percent / 100.0
95
+ threads = int(share + 0.5)
96
+ return max(1, min(MAX_INTRA_OP_THREADS, threads))
97
+
98
+
99
+ def resolve_budget(
100
+ settings: Settings,
101
+ *,
102
+ total_ram_bytes: int,
103
+ available_ram_bytes: int,
104
+ logical_cpus: int,
105
+ ) -> Budget:
106
+ """The ceiling this job will actually run under (F-20, F-21, F-23, N-04).
107
+
108
+ ``available_ram_bytes`` is what makes the answer a *current* one: the
109
+ applied memory ceiling is the configured value less whatever the host needs
110
+ to keep, so the same settings legitimately produce a smaller budget on a
111
+ busy machine. F-78 shows both numbers; nothing writes this one back.
112
+
113
+ Raises ``EchoActError(INSUFFICIENT_RESOURCES)`` when the result would fall
114
+ under F-23's 2 GiB floor. Returning a 1 GiB budget instead would let the
115
+ model load and then be killed by the halt check moments later, which F-23
116
+ rules out by saying the model is not loaded at all.
117
+ """
118
+ if total_ram_bytes <= 0 or logical_cpus <= 0:
119
+ raise AssertionError("resolve_budget needs a real machine description")
120
+
121
+ available = max(0, min(available_ram_bytes, total_ram_bytes))
122
+ configured = (
123
+ default_memory_bytes(total_ram_bytes)
124
+ if settings.memory_bytes is None
125
+ else settings.memory_bytes
126
+ )
127
+ # F-20 and F-21 are safety limits, not just widget ranges: a Settings built
128
+ # in code, or restored from another machine's file, is clamped here too.
129
+ configured = min(configured, memory_ceiling_bytes(total_ram_bytes))
130
+ cpu_percent = max(CPU_PERCENT_MIN, min(CPU_PERCENT_MAX, settings.cpu_percent))
131
+
132
+ headroom = system_headroom_bytes(total_ram_bytes)
133
+ applied = min(configured, available - headroom)
134
+
135
+ if applied < MEMORY_FLOOR_BYTES:
136
+ raise EchoActError(
137
+ Code.INSUFFICIENT_RESOURCES,
138
+ detail={
139
+ "required_bytes": MEMORY_FLOOR_BYTES,
140
+ "configured_bytes": configured,
141
+ "available_bytes": available,
142
+ "headroom_bytes": headroom,
143
+ "grantable_bytes": max(0, applied),
144
+ },
145
+ retry_after_s=RESOURCE_RETRY_AFTER_S,
146
+ )
147
+
148
+ return Budget(
149
+ cpu_percent=cpu_percent,
150
+ memory_bytes=int(applied),
151
+ intra_op_threads=intra_op_threads(logical_cpus, cpu_percent),
152
+ inter_op_threads=1,
153
+ )
154
+
155
+
156
+ def resolve_budget_from_system(settings: Settings) -> Budget:
157
+ """``resolve_budget`` against this machine, read once so the three numbers
158
+ describe the same instant."""
159
+ vm = psutil.virtual_memory()
160
+ return resolve_budget(
161
+ settings,
162
+ total_ram_bytes=int(vm.total),
163
+ available_ram_bytes=int(vm.available),
164
+ logical_cpus=psutil.cpu_count(logical=True) or 1,
165
+ )
166
+
167
+
168
+ # ======================================================================
169
+ # Measurement (N-04, N-21, F-22)
170
+ # ======================================================================
171
+
172
+
173
+ @dataclass(frozen=True, slots=True)
174
+ class ResourceSample:
175
+ """One reading of the generation job and of the host, taken together.
176
+
177
+ Both halves come from the same moment on purpose: F-23 halts on either the
178
+ job's own usage or the system's free memory, and comparing an RSS from now
179
+ against a free-memory figure from several seconds ago would halt jobs for
180
+ conditions that had already passed.
181
+ """
182
+
183
+ #: Worker resident set size. N-03 warns that this and the number the
184
+ #: operating system's limit acts on may differ; this is the displayed one.
185
+ rss_bytes: int
186
+ #: Share of *total logical CPU capacity*, per Section 4.1's display rule --
187
+ #: not psutil's per-core percentage, which exceeds 100 on a busy machine.
188
+ cpu_percent: float
189
+ system_total_bytes: int
190
+ system_available_bytes: int
191
+ at: float
192
+
193
+
194
+ class ResourceSampler:
195
+ """Reads the worker process's RSS and CPU at N-04's half-second cadence.
196
+
197
+ Points at the worker's pid, not at this process: N-21 requires the
198
+ generation job to be measured apart from total app usage, and the GUI, the
199
+ database, and the REST server all live in the parent.
200
+
201
+ It owns no thread and never sleeps. The job engine already runs a loop
202
+ that must stay responsive to cancellation within N-22's five seconds, so
203
+ the cadence is expressed as ``due()`` for that loop to consult; a private
204
+ timer thread here would sample a worker the engine had already killed.
205
+ """
206
+
207
+ def __init__(
208
+ self,
209
+ pid: int,
210
+ *,
211
+ interval_s: float = RESOURCE_SAMPLE_INTERVAL_S,
212
+ logical_cpus: int | None = None,
213
+ process: Any | None = None,
214
+ memory_probe: Callable[[], tuple[int, int]] | None = None,
215
+ clock: Callable[[], float] = monotonic,
216
+ ) -> None:
217
+ self.pid = pid
218
+ self.interval_s = interval_s
219
+ self._clock = clock
220
+ self._logical_cpus = max(1, logical_cpus or psutil.cpu_count(logical=True) or 1)
221
+ self._memory_probe = memory_probe or _system_memory
222
+ self._last_at: float | None = None
223
+ self._alive = True
224
+ if process is not None:
225
+ self._process = process
226
+ else:
227
+ try:
228
+ self._process = psutil.Process(pid)
229
+ except psutil.Error:
230
+ self._process = None
231
+ self._alive = False
232
+ else:
233
+ # Establishes psutil's baseline; the first real reading would
234
+ # otherwise report the process's whole lifetime average.
235
+ try:
236
+ self._process.cpu_percent(None)
237
+ except psutil.Error:
238
+ self._alive = False
239
+
240
+ @property
241
+ def alive(self) -> bool:
242
+ """False once the worker has gone. A killed worker is the normal end
243
+ of a cancelled job (N-22), so it is a state, not an error."""
244
+ return self._alive
245
+
246
+ def due(self, at: float | None = None) -> bool:
247
+ now = self._clock() if at is None else at
248
+ return self._last_at is None or (now - self._last_at) >= self.interval_s
249
+
250
+ def sample(self) -> ResourceSample | None:
251
+ """Read now, whether or not ``due()``. ``None`` means the worker is
252
+ gone and there is nothing left to measure."""
253
+ if self._process is None:
254
+ self._alive = False
255
+ return None
256
+ try:
257
+ rss = int(self._process.memory_info().rss)
258
+ raw_cpu = float(self._process.cpu_percent(None))
259
+ except psutil.Error:
260
+ self._alive = False
261
+ return None
262
+ total, available = self._memory_probe()
263
+ self._last_at = self._clock()
264
+ return ResourceSample(
265
+ rss_bytes=rss,
266
+ cpu_percent=round(raw_cpu / self._logical_cpus, 2),
267
+ system_total_bytes=total,
268
+ system_available_bytes=available,
269
+ at=self._last_at,
270
+ )
271
+
272
+ def poll(self) -> ResourceSample | None:
273
+ """Sample only if the interval has elapsed. ``None`` also means "not
274
+ yet", so check ``alive`` to tell the two apart."""
275
+ if not self.due():
276
+ return None
277
+ return self.sample()
278
+
279
+
280
+ def _system_memory() -> tuple[int, int]:
281
+ vm = psutil.virtual_memory()
282
+ return int(vm.total), int(vm.available)
283
+
284
+
285
+ # ======================================================================
286
+ # F-23's halt decision
287
+ # ======================================================================
288
+
289
+
290
+ class HaltReason(StrEnum):
291
+ """Why a running job must stop. F-23 requires the reason to be reported,
292
+ and the two causes call for different advice: one is the app's own budget,
293
+ the other is the rest of the machine."""
294
+
295
+ NONE = "none"
296
+ BUDGET_EXCEEDED = "budget_exceeded"
297
+ SYSTEM_HEADROOM = "system_headroom"
298
+
299
+
300
+ @dataclass(frozen=True, slots=True)
301
+ class HaltDecision:
302
+ should_halt: bool
303
+ reason: HaltReason = HaltReason.NONE
304
+ message: str = ""
305
+ detail: Mapping[str, Any] = field(default_factory=dict)
306
+
307
+ def to_error(self) -> EchoActError:
308
+ """The failure to fail the job with. F-23 also releases the model;
309
+ that is the job engine's to do, because only it holds the worker."""
310
+ if not self.should_halt:
311
+ raise AssertionError("no halt was decided")
312
+ return EchoActError(
313
+ Code.OUT_OF_MEMORY,
314
+ self.message,
315
+ detail={"reason": self.reason.value, **dict(self.detail)},
316
+ retry_after_s=RESOURCE_RETRY_AFTER_S,
317
+ )
318
+
319
+
320
+ _CONTINUE: Final = HaltDecision(should_halt=False)
321
+
322
+
323
+ def halt_decision(sample: ResourceSample, budget: Budget) -> HaltDecision:
324
+ """F-23: halt when the job is over its limit, or the host is short.
325
+
326
+ Only memory decides this. Halting a job for exceeding its CPU share would
327
+ be wrong twice over: N-03 makes CPU the operating-system container's to
328
+ throttle rather than the app's to police, and N-04 already concedes that
329
+ momentary spikes between half-second samples are not prevented -- so a
330
+ single busy interval would kill a job the requirements say should merely
331
+ run more slowly. Memory is different: an RSS reading that is over budget
332
+ is over budget until something frees it.
333
+ """
334
+ if sample.rss_bytes > budget.memory_bytes:
335
+ return HaltDecision(
336
+ should_halt=True,
337
+ reason=HaltReason.BUDGET_EXCEEDED,
338
+ message="The job was halted because it exceeded its memory budget.",
339
+ detail={"rss_bytes": sample.rss_bytes, "limit_bytes": budget.memory_bytes},
340
+ )
341
+
342
+ headroom = system_headroom_bytes(sample.system_total_bytes)
343
+ if sample.system_available_bytes < headroom:
344
+ return HaltDecision(
345
+ should_halt=True,
346
+ reason=HaltReason.SYSTEM_HEADROOM,
347
+ message="The job was halted because the computer ran short of free memory.",
348
+ detail={
349
+ "available_bytes": sample.system_available_bytes,
350
+ "headroom_bytes": headroom,
351
+ },
352
+ )
353
+ return _CONTINUE
354
+
355
+
356
+ __all__ = [
357
+ "MAX_INTRA_OP_THREADS",
358
+ "RESOURCE_RETRY_AFTER_S",
359
+ "HaltDecision",
360
+ "HaltReason",
361
+ "ResourceSample",
362
+ "ResourceSampler",
363
+ "default_memory_bytes",
364
+ "halt_decision",
365
+ "intra_op_threads",
366
+ "memory_ceiling_bytes",
367
+ "resolve_budget",
368
+ "resolve_budget_from_system",
369
+ "system_headroom_bytes",
370
+ ]