convilyn-edge 0.1.0b3__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.
- convilyn_edge/__init__.py +132 -0
- convilyn_edge/_version.py +15 -0
- convilyn_edge/cli/__init__.py +7 -0
- convilyn_edge/cli/main.py +97 -0
- convilyn_edge/cli/scaffold.py +137 -0
- convilyn_edge/clientcompute/__init__.py +74 -0
- convilyn_edge/clientcompute/bridge.py +184 -0
- convilyn_edge/clientcompute/contract.py +226 -0
- convilyn_edge/clientcompute/engine.py +239 -0
- convilyn_edge/clientcompute/operator.py +130 -0
- convilyn_edge/envelope.py +205 -0
- convilyn_edge/offline/__init__.py +40 -0
- convilyn_edge/offline/emitter.py +122 -0
- convilyn_edge/offline/idempotency.py +60 -0
- convilyn_edge/offline/queue.py +168 -0
- convilyn_edge/probe.py +239 -0
- convilyn_edge/py.typed +0 -0
- convilyn_edge/result.py +118 -0
- convilyn_edge/simulator.py +133 -0
- convilyn_edge/spi/__init__.py +92 -0
- convilyn_edge/spi/action.py +120 -0
- convilyn_edge/spi/model.py +112 -0
- convilyn_edge/spi/normalizer.py +54 -0
- convilyn_edge/spi/operator.py +71 -0
- convilyn_edge/spi/review.py +80 -0
- convilyn_edge/spi/source.py +78 -0
- convilyn_edge/spi/state.py +48 -0
- convilyn_edge-0.1.0b3.dist-info/METADATA +194 -0
- convilyn_edge-0.1.0b3.dist-info/RECORD +32 -0
- convilyn_edge-0.1.0b3.dist-info/WHEEL +4 -0
- convilyn_edge-0.1.0b3.dist-info/entry_points.txt +2 -0
- convilyn_edge-0.1.0b3.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Convilyn Edge — the Edge AI Workflow SDK (``convilyn-edge``).
|
|
2
|
+
|
|
3
|
+
The Device Data Plane + Edge Runtime SPI: seven typed Protocols that let a
|
|
4
|
+
developer compose device events, environment state, deterministic rules, models,
|
|
5
|
+
human confirmation and gated actions into an auditable, offline-capable edge AI
|
|
6
|
+
workflow — without the substrate ever encoding one vertical scenario.
|
|
7
|
+
|
|
8
|
+
Quick start — define a Source and drive it (v0.1 is the SPI; the retail pack +
|
|
9
|
+
simulator land in PR4/PR5)::
|
|
10
|
+
|
|
11
|
+
from convilyn_edge import EventEnvelope, EventSource, new_envelope
|
|
12
|
+
|
|
13
|
+
async for event in my_source.start(ctx): # my_source: EventSource
|
|
14
|
+
... # event is an EventEnvelope
|
|
15
|
+
|
|
16
|
+
Design invariants (the binding principles, expressed in code):
|
|
17
|
+
|
|
18
|
+
* The device is never a second source of truth — the server holds the 7 hard
|
|
19
|
+
zones and re-grounds every device value. The edge SPI *inherits* that contract.
|
|
20
|
+
* One envelope shape, one Result type, one observability convention.
|
|
21
|
+
* No LLM in the deterministic operators; no ``if scenario == ...`` in the
|
|
22
|
+
substrate. Scenario logic lives in a removable Solution Pack.
|
|
23
|
+
|
|
24
|
+
``convilyn_edge`` (this top level) is the stable public surface — everything here
|
|
25
|
+
follows the reference-architecture's independent-versioning rule (§十六). Nothing
|
|
26
|
+
in this package imports ``backend-api`` or any other SDK at import time; runtime
|
|
27
|
+
dependencies are zero.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from convilyn_edge._version import __version__
|
|
33
|
+
from convilyn_edge.envelope import (
|
|
34
|
+
SPEC_VERSION,
|
|
35
|
+
Correlation,
|
|
36
|
+
EventEnvelope,
|
|
37
|
+
EventSourceRef,
|
|
38
|
+
new_envelope,
|
|
39
|
+
)
|
|
40
|
+
from convilyn_edge.probe import (
|
|
41
|
+
Capability,
|
|
42
|
+
DeviceCapabilityManifest,
|
|
43
|
+
InstalledAsset,
|
|
44
|
+
QualityTier,
|
|
45
|
+
probe_device,
|
|
46
|
+
)
|
|
47
|
+
from convilyn_edge.result import Err, Ok, Result
|
|
48
|
+
from convilyn_edge.spi import (
|
|
49
|
+
ActionAuthorization,
|
|
50
|
+
ActionDescriptor,
|
|
51
|
+
ActionResult,
|
|
52
|
+
ActionSink,
|
|
53
|
+
ActionStatus,
|
|
54
|
+
DefaultAction,
|
|
55
|
+
DeterministicOperator,
|
|
56
|
+
DeviceHealth,
|
|
57
|
+
EventContext,
|
|
58
|
+
EventSource,
|
|
59
|
+
Evidence,
|
|
60
|
+
ExecutionContext,
|
|
61
|
+
HealthStatus,
|
|
62
|
+
HumanReview,
|
|
63
|
+
ModelOperator,
|
|
64
|
+
ModelResult,
|
|
65
|
+
ModelStatus,
|
|
66
|
+
NormalizeError,
|
|
67
|
+
Normalizer,
|
|
68
|
+
OperatorError,
|
|
69
|
+
Placement,
|
|
70
|
+
ReviewChoice,
|
|
71
|
+
ReviewDecision,
|
|
72
|
+
ReviewOutcome,
|
|
73
|
+
ReviewRequest,
|
|
74
|
+
RiskLevel,
|
|
75
|
+
SourceContext,
|
|
76
|
+
StateProvider,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
__all__ = [
|
|
80
|
+
"__version__",
|
|
81
|
+
# Result
|
|
82
|
+
"Ok",
|
|
83
|
+
"Err",
|
|
84
|
+
"Result",
|
|
85
|
+
# Envelope
|
|
86
|
+
"EventEnvelope",
|
|
87
|
+
"EventSourceRef",
|
|
88
|
+
"Correlation",
|
|
89
|
+
"new_envelope",
|
|
90
|
+
"SPEC_VERSION",
|
|
91
|
+
# Device-capability probe (Epic #2556 — multi-device IoT readiness)
|
|
92
|
+
"probe_device",
|
|
93
|
+
"DeviceCapabilityManifest",
|
|
94
|
+
"InstalledAsset",
|
|
95
|
+
"Capability",
|
|
96
|
+
"QualityTier",
|
|
97
|
+
# SPI — 1. Source
|
|
98
|
+
"EventSource",
|
|
99
|
+
"SourceContext",
|
|
100
|
+
"DeviceHealth",
|
|
101
|
+
"HealthStatus",
|
|
102
|
+
# SPI — 2. Normalizer
|
|
103
|
+
"Normalizer",
|
|
104
|
+
"NormalizeError",
|
|
105
|
+
# SPI — 3. StateProvider
|
|
106
|
+
"StateProvider",
|
|
107
|
+
"EventContext",
|
|
108
|
+
# SPI — 4. DeterministicOperator
|
|
109
|
+
"DeterministicOperator",
|
|
110
|
+
"ExecutionContext",
|
|
111
|
+
"OperatorError",
|
|
112
|
+
# SPI — 5. ModelOperator
|
|
113
|
+
"ModelOperator",
|
|
114
|
+
"ModelResult",
|
|
115
|
+
"Evidence",
|
|
116
|
+
"Placement",
|
|
117
|
+
"ModelStatus",
|
|
118
|
+
# SPI — 6. HumanReview
|
|
119
|
+
"HumanReview",
|
|
120
|
+
"ReviewRequest",
|
|
121
|
+
"ReviewChoice",
|
|
122
|
+
"ReviewOutcome",
|
|
123
|
+
"DefaultAction",
|
|
124
|
+
"ReviewDecision",
|
|
125
|
+
# SPI — 7. ActionSink
|
|
126
|
+
"ActionSink",
|
|
127
|
+
"ActionDescriptor",
|
|
128
|
+
"ActionAuthorization",
|
|
129
|
+
"ActionResult",
|
|
130
|
+
"RiskLevel",
|
|
131
|
+
"ActionStatus",
|
|
132
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Single source of truth for ``convilyn-edge``'s package version.
|
|
2
|
+
|
|
3
|
+
Read by both:
|
|
4
|
+
|
|
5
|
+
* ``pyproject.toml`` via ``[tool.hatch.version]`` (so the wheel metadata +
|
|
6
|
+
``importlib.metadata.version("convilyn-edge")`` agree)
|
|
7
|
+
* ``convilyn_edge.__init__`` for the in-process ``__version__`` attribute.
|
|
8
|
+
|
|
9
|
+
The SDK versions its *surface* independently of any device / adapter /
|
|
10
|
+
workflow / runtime version (reference-architecture §十六) — this is only the
|
|
11
|
+
Core API + SPI version. Bump in CHANGELOG-bumping commits; never edit a
|
|
12
|
+
hardcoded constant in two places.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0b3"
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""The ``convilyn-edge`` CLI (stdlib ``argparse`` — zero dependencies).
|
|
2
|
+
|
|
3
|
+
Commands:
|
|
4
|
+
|
|
5
|
+
* ``simulate <scenario.json> [--no-delay]`` — replay a scenario through the
|
|
6
|
+
:class:`~convilyn_edge.simulator.SimulatedSource`, printing each
|
|
7
|
+
``EventEnvelope`` as a wire-JSON line (drive a workflow with no hardware).
|
|
8
|
+
* ``init adapter|workflow <name> [--path DIR]`` — scaffold a device-adapter or
|
|
9
|
+
workflow skeleton.
|
|
10
|
+
|
|
11
|
+
``dev run`` and ``trace replay`` are deferred to v0.2 (they need the workflow
|
|
12
|
+
executor + a trace-export format that are out of the v0.1 scope); ``simulate``
|
|
13
|
+
is the v0.1 device-plane dev-run, and ``--no-delay`` gives a deterministic replay.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import asyncio
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from convilyn_edge._version import __version__
|
|
25
|
+
from convilyn_edge.cli.scaffold import scaffold_adapter, scaffold_workflow
|
|
26
|
+
from convilyn_edge.simulator import Scenario, SimulatedSource
|
|
27
|
+
from convilyn_edge.spi.source import SourceContext
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def _drive(scenario: Scenario, no_delay: bool) -> int:
|
|
31
|
+
source = SimulatedSource(scenario, no_delay=no_delay)
|
|
32
|
+
ctx = SourceContext(device_id=scenario.source.device_id, site_id=scenario.site_id)
|
|
33
|
+
count = 0
|
|
34
|
+
async for envelope in source.start(ctx):
|
|
35
|
+
print(json.dumps(envelope.to_wire(), ensure_ascii=False))
|
|
36
|
+
count += 1
|
|
37
|
+
return count
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def cmd_simulate(args: argparse.Namespace) -> int:
|
|
41
|
+
scenario_path = Path(args.scenario)
|
|
42
|
+
if not scenario_path.is_file():
|
|
43
|
+
print(f"no such scenario file: {scenario_path}", file=sys.stderr)
|
|
44
|
+
return 2
|
|
45
|
+
try:
|
|
46
|
+
scenario = Scenario.load(scenario_path)
|
|
47
|
+
except (json.JSONDecodeError, KeyError, ValueError) as exc:
|
|
48
|
+
print(f"malformed scenario {scenario_path.name}: {exc}", file=sys.stderr)
|
|
49
|
+
return 2
|
|
50
|
+
count = asyncio.run(_drive(scenario, args.no_delay))
|
|
51
|
+
print(f"simulated {count} event(s) from {scenario_path.name}", file=sys.stderr)
|
|
52
|
+
return 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def cmd_init(args: argparse.Namespace) -> int:
|
|
56
|
+
root = Path(args.path) if args.path else Path.cwd()
|
|
57
|
+
scaffold = scaffold_adapter if args.kind == "adapter" else scaffold_workflow
|
|
58
|
+
try:
|
|
59
|
+
created = scaffold(root, args.name)
|
|
60
|
+
except (ValueError, FileExistsError) as exc:
|
|
61
|
+
print(f"cannot scaffold {args.kind} '{args.name}': {exc}", file=sys.stderr)
|
|
62
|
+
return 2
|
|
63
|
+
print(f"scaffolded {args.kind} '{args.name}' at {created}", file=sys.stderr)
|
|
64
|
+
return 0
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
68
|
+
parser = argparse.ArgumentParser(
|
|
69
|
+
prog="convilyn-edge",
|
|
70
|
+
description="Convilyn Edge AI Workflow SDK — device simulator + scaffolds.",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument("--version", action="version", version=f"convilyn-edge {__version__}")
|
|
73
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
74
|
+
|
|
75
|
+
simulate = sub.add_parser("simulate", help="replay a scenario through the simulator")
|
|
76
|
+
simulate.add_argument("scenario", help="path to a scenario JSON file")
|
|
77
|
+
simulate.add_argument(
|
|
78
|
+
"--no-delay", action="store_true", help="ignore per-event delays (deterministic replay)"
|
|
79
|
+
)
|
|
80
|
+
simulate.set_defaults(func=cmd_simulate)
|
|
81
|
+
|
|
82
|
+
init = sub.add_parser("init", help="scaffold an adapter or workflow skeleton")
|
|
83
|
+
init.add_argument("kind", choices=["adapter", "workflow"])
|
|
84
|
+
init.add_argument("name", help="the adapter / workflow name")
|
|
85
|
+
init.add_argument("--path", default=None, help="root directory (default: cwd)")
|
|
86
|
+
init.set_defaults(func=cmd_init)
|
|
87
|
+
|
|
88
|
+
return parser
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main(argv: list[str] | None = None) -> int:
|
|
92
|
+
args = build_parser().parse_args(argv)
|
|
93
|
+
return int(args.func(args))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__": # pragma: no cover
|
|
97
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Scaffold generators for ``convilyn-edge init adapter|workflow`` (reference §十四).
|
|
2
|
+
|
|
3
|
+
Writes a conventional skeleton so a developer adding a device adapter or a
|
|
4
|
+
workflow starts from a consistent layout, not a blank directory. Pure stdlib
|
|
5
|
+
(pathlib); every file is a commented placeholder the author fills in.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
# A scaffold name is a single path component: alnum-led, then [A-Za-z0-9._-]. This
|
|
14
|
+
# rejects path separators, ``..``, absolute/anchored names (which would escape the
|
|
15
|
+
# target root — ``root / "adapters" / "/tmp/x"`` == ``/tmp/x``), and newlines / ``: ``
|
|
16
|
+
# that would inject into the generated YAML.
|
|
17
|
+
_SAFE_NAME = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _validate_name(name: str) -> None:
|
|
21
|
+
if not _SAFE_NAME.match(name):
|
|
22
|
+
raise ValueError(
|
|
23
|
+
f"invalid name {name!r}: use [A-Za-z0-9._-] (alnum-led), no path separators or '..'"
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_ADAPTER_YAML = """\
|
|
28
|
+
# Device adapter manifest for '{name}' (reference §二 Device Capability Manifest).
|
|
29
|
+
apiVersion: convilyn.io/v1alpha1
|
|
30
|
+
kind: DeviceProfile
|
|
31
|
+
metadata:
|
|
32
|
+
name: {name}
|
|
33
|
+
vendor: generic
|
|
34
|
+
model: "*"
|
|
35
|
+
capabilities:
|
|
36
|
+
properties: {{}}
|
|
37
|
+
events: {{}}
|
|
38
|
+
actions: {{}}
|
|
39
|
+
bindings: []
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
_ADAPTER_README = """\
|
|
43
|
+
# {name} adapter
|
|
44
|
+
|
|
45
|
+
An `EventSource` (+ `Normalizer`) that turns this device's raw output into the
|
|
46
|
+
canonical `EventEnvelope`. Implement `convilyn_edge.spi.EventSource` in `src/`.
|
|
47
|
+
|
|
48
|
+
- `schemas/` — the canonical event schema(s) this adapter emits
|
|
49
|
+
- `src/` — the adapter implementation
|
|
50
|
+
- `fixtures/` — recorded raw payloads for tests
|
|
51
|
+
- `contract-tests/` — assert the adapter emits the canonical envelope
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
_WORKFLOW_YAML = """\
|
|
55
|
+
# Edge workflow '{name}' (reference §五 — a typed DAG, never an agent prompt).
|
|
56
|
+
apiVersion: convilyn.io/v1alpha1
|
|
57
|
+
kind: EdgeWorkflow
|
|
58
|
+
metadata:
|
|
59
|
+
name: {name}
|
|
60
|
+
version: 0.1.0
|
|
61
|
+
trigger:
|
|
62
|
+
event: device.example.received
|
|
63
|
+
steps: []
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
_WORKFLOW_README = """\
|
|
67
|
+
# {name} workflow
|
|
68
|
+
|
|
69
|
+
Compose deterministic operators, a model operator, human review and gated
|
|
70
|
+
actions over device events. Each step declares its input/output schema,
|
|
71
|
+
placement, deadline and required permissions.
|
|
72
|
+
|
|
73
|
+
- `schemas/` — input/output schemas
|
|
74
|
+
- `policies/` — retry / timeout / human-review / fallback policy
|
|
75
|
+
- `prompts/` — model-operator prompt(s)
|
|
76
|
+
- `tests/` — unit tests
|
|
77
|
+
- `golden-cases/` — recorded input → expected decision
|
|
78
|
+
- `deployment/` — resource limits + kill-switch config
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
_ADAPTER_DIRS = ("schemas", "src", "fixtures", "contract-tests")
|
|
82
|
+
_WORKFLOW_DIRS = ("schemas", "policies", "prompts", "tests", "golden-cases", "deployment")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _write(path: Path, content: str) -> None:
|
|
86
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
path.write_text(content, encoding="utf-8")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _scaffold(base: Path, dirs: tuple[str, ...], files: dict[str, str]) -> Path:
|
|
91
|
+
for directory in dirs:
|
|
92
|
+
(base / directory).mkdir(parents=True, exist_ok=True)
|
|
93
|
+
(base / directory / ".gitkeep").write_text("", encoding="utf-8")
|
|
94
|
+
for filename, content in files.items():
|
|
95
|
+
_write(base / filename, content)
|
|
96
|
+
return base
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def scaffold_adapter(root: Path, name: str) -> Path:
|
|
100
|
+
"""Create ``<root>/adapters/<name>/`` with the adapter skeleton. Returns it.
|
|
101
|
+
|
|
102
|
+
Raises ``ValueError`` for an unsafe ``name`` and ``FileExistsError`` if the
|
|
103
|
+
target already exists (a scaffold never silently overwrites authored files)."""
|
|
104
|
+
_validate_name(name)
|
|
105
|
+
base = root / "adapters" / name
|
|
106
|
+
if base.exists():
|
|
107
|
+
raise FileExistsError(f"{base} already exists — remove it or choose another name")
|
|
108
|
+
return _scaffold(
|
|
109
|
+
base,
|
|
110
|
+
_ADAPTER_DIRS,
|
|
111
|
+
{
|
|
112
|
+
"adapter.yaml": _ADAPTER_YAML.format(name=name),
|
|
113
|
+
"README.md": _ADAPTER_README.format(name=name),
|
|
114
|
+
},
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def scaffold_workflow(root: Path, name: str) -> Path:
|
|
119
|
+
"""Create ``<root>/workflows/<name>/`` with the workflow skeleton. Returns it.
|
|
120
|
+
|
|
121
|
+
Raises ``ValueError`` for an unsafe ``name`` and ``FileExistsError`` if the
|
|
122
|
+
target already exists."""
|
|
123
|
+
_validate_name(name)
|
|
124
|
+
base = root / "workflows" / name
|
|
125
|
+
if base.exists():
|
|
126
|
+
raise FileExistsError(f"{base} already exists — remove it or choose another name")
|
|
127
|
+
return _scaffold(
|
|
128
|
+
base,
|
|
129
|
+
_WORKFLOW_DIRS,
|
|
130
|
+
{
|
|
131
|
+
"workflow.yaml": _WORKFLOW_YAML.format(name=name),
|
|
132
|
+
"README.md": _WORKFLOW_README.format(name=name),
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
__all__ = ["scaffold_adapter", "scaffold_workflow"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Client-compute — the on-device extractor keystone (contract v1 consumer).
|
|
2
|
+
|
|
3
|
+
Productizes the device half of the frozen ``client_compute`` interrupt contract:
|
|
4
|
+
the cloud delegates the extractor role to the device, the device runs a local
|
|
5
|
+
model over its OWN copy of the file and returns grounded anchors, the server
|
|
6
|
+
re-grounds them. ``convilyn-edge`` **confirms-and-consumes** the contract.
|
|
7
|
+
|
|
8
|
+
from convilyn_edge.clientcompute import (
|
|
9
|
+
ClientComputeBridge, EdgeModelOperator, HttpLocalExtractor,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
operator = EdgeModelOperator(HttpLocalExtractor.from_env(os.environ))
|
|
13
|
+
bridge = ClientComputeBridge(operator, my_file_resolver)
|
|
14
|
+
job = await bridge.handle_if_present(client.goals, job) # client: AsyncConvilyn
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from convilyn_edge.clientcompute.bridge import (
|
|
20
|
+
ClientComputeBridge,
|
|
21
|
+
ClientComputeError,
|
|
22
|
+
FileTextResolver,
|
|
23
|
+
GoalClientPort,
|
|
24
|
+
GoalJobLike,
|
|
25
|
+
MissingLocalSourceError,
|
|
26
|
+
find_client_compute_interrupt,
|
|
27
|
+
)
|
|
28
|
+
from convilyn_edge.clientcompute.contract import (
|
|
29
|
+
DEFAULT_MAX_TOTAL_CHARS,
|
|
30
|
+
DEFAULT_MAX_VALUE_CHARS,
|
|
31
|
+
INTERRUPT_TYPE,
|
|
32
|
+
MISSING_SENTINEL,
|
|
33
|
+
STRATEGY_V1,
|
|
34
|
+
AnchorsContract,
|
|
35
|
+
ClientComputeRequest,
|
|
36
|
+
build_resume_answer,
|
|
37
|
+
ground_anchors,
|
|
38
|
+
)
|
|
39
|
+
from convilyn_edge.clientcompute.engine import (
|
|
40
|
+
HttpLocalExtractor,
|
|
41
|
+
LocalExtractor,
|
|
42
|
+
build_extract_messages,
|
|
43
|
+
resolve_local_model_tag,
|
|
44
|
+
)
|
|
45
|
+
from convilyn_edge.clientcompute.operator import EdgeModelOperator, ExtractInput
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
# contract
|
|
49
|
+
"INTERRUPT_TYPE",
|
|
50
|
+
"STRATEGY_V1",
|
|
51
|
+
"MISSING_SENTINEL",
|
|
52
|
+
"DEFAULT_MAX_VALUE_CHARS",
|
|
53
|
+
"DEFAULT_MAX_TOTAL_CHARS",
|
|
54
|
+
"AnchorsContract",
|
|
55
|
+
"ClientComputeRequest",
|
|
56
|
+
"ground_anchors",
|
|
57
|
+
"build_resume_answer",
|
|
58
|
+
# engine
|
|
59
|
+
"LocalExtractor",
|
|
60
|
+
"HttpLocalExtractor",
|
|
61
|
+
"resolve_local_model_tag",
|
|
62
|
+
"build_extract_messages",
|
|
63
|
+
# operator
|
|
64
|
+
"EdgeModelOperator",
|
|
65
|
+
"ExtractInput",
|
|
66
|
+
# bridge
|
|
67
|
+
"ClientComputeBridge",
|
|
68
|
+
"ClientComputeError",
|
|
69
|
+
"MissingLocalSourceError",
|
|
70
|
+
"FileTextResolver",
|
|
71
|
+
"GoalJobLike",
|
|
72
|
+
"GoalClientPort",
|
|
73
|
+
"find_client_compute_interrupt",
|
|
74
|
+
]
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""``ClientComputeBridge`` — drive one ``client_compute`` interrupt to resume.
|
|
2
|
+
|
|
3
|
+
The round-trip the keystone proves, end to end:
|
|
4
|
+
|
|
5
|
+
cloud pauses with a client_compute interrupt
|
|
6
|
+
→ bridge parses the frozen payload (contract.py)
|
|
7
|
+
→ resolves each file_id to the device's OWN local text (FileTextResolver)
|
|
8
|
+
→ EdgeModelOperator.infer runs the local model + grounds (operator.py)
|
|
9
|
+
→ bridge submits {anchors, nonce} via the consumer SDK's fill_slots
|
|
10
|
+
→ server re-grounds and resumes
|
|
11
|
+
|
|
12
|
+
The bridge depends only on **narrow Protocols** (DIP) — never on the consumer SDK
|
|
13
|
+
package — so ``convilyn-edge`` stays zero-dependency: the caller injects a live
|
|
14
|
+
``AsyncConvilyn().goals`` (which satisfies :class:`GoalClientPort` structurally)
|
|
15
|
+
and a resolver that maps a ``file_id`` to the local file's text. Privacy invariant
|
|
16
|
+
(contract §5.2): the interrupt carries file *references* only; the device reads its
|
|
17
|
+
own copy — file bytes never leave the device.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Mapping, Sequence
|
|
23
|
+
from typing import Any, Protocol
|
|
24
|
+
|
|
25
|
+
from convilyn_edge.clientcompute.contract import (
|
|
26
|
+
INTERRUPT_TYPE,
|
|
27
|
+
ClientComputeRequest,
|
|
28
|
+
build_resume_answer,
|
|
29
|
+
)
|
|
30
|
+
from convilyn_edge.clientcompute.operator import EdgeModelOperator, ExtractInput
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ClientComputeError(Exception):
|
|
34
|
+
"""The bridge could not fulfil a client_compute interrupt."""
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class MissingLocalSourceError(ClientComputeError):
|
|
38
|
+
"""The interrupt referenced files but none resolved to local text on this
|
|
39
|
+
device — a fail-loud misconfiguration (the extraction cannot be meaningful),
|
|
40
|
+
never a silent all-sentinel submission."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class FileTextResolver(Protocol):
|
|
44
|
+
"""Maps a delegation ``file_id`` to the device's own local copy of its text.
|
|
45
|
+
|
|
46
|
+
The device reads its OWN file (the point of on-device compute); returning
|
|
47
|
+
``None`` means "this device has no local copy of that file"."""
|
|
48
|
+
|
|
49
|
+
def resolve(self, file_id: str) -> str | None:
|
|
50
|
+
"""Return the local text for ``file_id``, or ``None`` if unavailable."""
|
|
51
|
+
...
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GoalJobLike(Protocol):
|
|
55
|
+
"""The subset of a consumer-SDK ``GoalJob`` the bridge reads."""
|
|
56
|
+
|
|
57
|
+
job_spec_id: str
|
|
58
|
+
item_version: int | None
|
|
59
|
+
pending_interrupts: Sequence[Mapping[str, Any]]
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class GoalClientPort(Protocol):
|
|
63
|
+
"""The subset of the consumer SDK's ``goals`` surface the bridge drives.
|
|
64
|
+
|
|
65
|
+
A live ``AsyncConvilyn().goals`` satisfies this structurally."""
|
|
66
|
+
|
|
67
|
+
async def fill_slots(
|
|
68
|
+
self,
|
|
69
|
+
job_spec_id: str,
|
|
70
|
+
answers: Mapping[str, Any],
|
|
71
|
+
*,
|
|
72
|
+
expected_version: int | None = None,
|
|
73
|
+
) -> GoalJobLike:
|
|
74
|
+
"""Submit slot/interrupt answers, returning the updated job."""
|
|
75
|
+
...
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def find_client_compute_interrupt(job: GoalJobLike) -> Mapping[str, Any] | None:
|
|
79
|
+
"""Return the first pending ``client_compute`` interrupt on ``job``, or None."""
|
|
80
|
+
for entry in job.pending_interrupts:
|
|
81
|
+
if entry.get("interruptType") == INTERRUPT_TYPE and entry.get("status") == "pending":
|
|
82
|
+
return entry
|
|
83
|
+
return None
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _anchor_schema(required_anchors: Sequence[str]) -> dict[str, Any]:
|
|
87
|
+
"""A minimal JSON Schema for the anchor object (SPI conformance)."""
|
|
88
|
+
return {
|
|
89
|
+
"type": "object",
|
|
90
|
+
"properties": {key: {"type": "string"} for key in required_anchors},
|
|
91
|
+
"required": list(required_anchors),
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class ClientComputeBridge:
|
|
96
|
+
"""Fulfil ``client_compute`` interrupts with a device-local model."""
|
|
97
|
+
|
|
98
|
+
def __init__(
|
|
99
|
+
self,
|
|
100
|
+
operator: EdgeModelOperator,
|
|
101
|
+
file_resolver: FileTextResolver,
|
|
102
|
+
) -> None:
|
|
103
|
+
self._operator = operator
|
|
104
|
+
self._file_resolver = file_resolver
|
|
105
|
+
|
|
106
|
+
def _resolve_sources(self, request: ClientComputeRequest) -> dict[str, str]:
|
|
107
|
+
"""Map each referenced ``file_id`` to its local text. Raises
|
|
108
|
+
:class:`MissingLocalSourceError` if files were referenced but none
|
|
109
|
+
resolved."""
|
|
110
|
+
sources: dict[str, str] = {}
|
|
111
|
+
for file_id in request.file_ids:
|
|
112
|
+
text = self._file_resolver.resolve(file_id)
|
|
113
|
+
if text is not None:
|
|
114
|
+
sources[file_id] = text
|
|
115
|
+
if request.file_ids and not sources:
|
|
116
|
+
raise MissingLocalSourceError(
|
|
117
|
+
f"client_compute referenced {len(request.file_ids)} file(s) but none "
|
|
118
|
+
"resolved to local text on this device"
|
|
119
|
+
)
|
|
120
|
+
return sources
|
|
121
|
+
|
|
122
|
+
async def handle_interrupt(
|
|
123
|
+
self,
|
|
124
|
+
client: GoalClientPort,
|
|
125
|
+
job: GoalJobLike,
|
|
126
|
+
interrupt: Mapping[str, Any],
|
|
127
|
+
*,
|
|
128
|
+
deadline_ms: int | None = None,
|
|
129
|
+
) -> GoalJobLike:
|
|
130
|
+
"""Run the local extractor for one ``client_compute`` ``interrupt`` and
|
|
131
|
+
submit the grounded anchors via ``client.fill_slots``. Returns the updated
|
|
132
|
+
job."""
|
|
133
|
+
request = ClientComputeRequest.from_payload(interrupt.get("payload") or {})
|
|
134
|
+
sources = self._resolve_sources(request)
|
|
135
|
+
|
|
136
|
+
result = await self._operator.infer(
|
|
137
|
+
ExtractInput(
|
|
138
|
+
prompt=request.extractor_prompt,
|
|
139
|
+
sources=sources,
|
|
140
|
+
required_anchors=request.required_anchors,
|
|
141
|
+
contract=request.anchors_contract,
|
|
142
|
+
),
|
|
143
|
+
schema=_anchor_schema(request.required_anchors),
|
|
144
|
+
deadline_ms=deadline_ms,
|
|
145
|
+
placement="edge",
|
|
146
|
+
)
|
|
147
|
+
# Even an "unavailable" result yields a total all-sentinel answer, so the
|
|
148
|
+
# server can re-ground (→ all indeterminate) and resume rather than hang.
|
|
149
|
+
anchors = result.output or {
|
|
150
|
+
key: request.anchors_contract.missing_sentinel for key in request.required_anchors
|
|
151
|
+
}
|
|
152
|
+
answer = build_resume_answer(anchors, request.nonce)
|
|
153
|
+
interrupt_id = interrupt["interruptId"]
|
|
154
|
+
return await client.fill_slots(
|
|
155
|
+
job.job_spec_id,
|
|
156
|
+
{interrupt_id: answer},
|
|
157
|
+
expected_version=job.item_version,
|
|
158
|
+
)
|
|
159
|
+
|
|
160
|
+
async def handle_if_present(
|
|
161
|
+
self,
|
|
162
|
+
client: GoalClientPort,
|
|
163
|
+
job: GoalJobLike,
|
|
164
|
+
*,
|
|
165
|
+
deadline_ms: int | None = None,
|
|
166
|
+
) -> GoalJobLike | None:
|
|
167
|
+
"""If ``job`` has a pending client_compute interrupt, fulfil it and return
|
|
168
|
+
the updated job; otherwise return ``None`` (nothing to do — e.g. the
|
|
169
|
+
server-side delegation kill-switch is OFF)."""
|
|
170
|
+
interrupt = find_client_compute_interrupt(job)
|
|
171
|
+
if interrupt is None:
|
|
172
|
+
return None
|
|
173
|
+
return await self.handle_interrupt(client, job, interrupt, deadline_ms=deadline_ms)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
__all__ = [
|
|
177
|
+
"ClientComputeError",
|
|
178
|
+
"MissingLocalSourceError",
|
|
179
|
+
"FileTextResolver",
|
|
180
|
+
"GoalJobLike",
|
|
181
|
+
"GoalClientPort",
|
|
182
|
+
"ClientComputeBridge",
|
|
183
|
+
"find_client_compute_interrupt",
|
|
184
|
+
]
|