experiment-core 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.
- experiment_core/__init__.py +8 -0
- experiment_core/adjustment_guidance.py +49 -0
- experiment_core/agent_contracts.py +200 -0
- experiment_core/agent_roles.py +135 -0
- experiment_core/background.py +98 -0
- experiment_core/configuration.py +250 -0
- experiment_core/errors.py +47 -0
- experiment_core/execution.py +307 -0
- experiment_core/interfaces.py +54 -0
- experiment_core/invocation.py +30 -0
- experiment_core/measurement.py +25 -0
- experiment_core/operating_mode.py +123 -0
- experiment_core/ownership.py +192 -0
- experiment_core/records.py +245 -0
- experiment_core/schemas/protocol.json +984 -0
- experiment_core/testing.py +45 -0
- experiment_core/validation.py +75 -0
- experiment_core-0.1.0.dist-info/METADATA +45 -0
- experiment_core-0.1.0.dist-info/RECORD +22 -0
- experiment_core-0.1.0.dist-info/WHEEL +5 -0
- experiment_core-0.1.0.dist-info/licenses/LICENSE +21 -0
- experiment_core-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
"""Domain-neutral contracts and an explicit local reference runtime.
|
|
2
|
+
|
|
3
|
+
Runtime components are imported from their documented modules. Importing this
|
|
4
|
+
package does not create stores, register applications or contact any service.
|
|
5
|
+
"""
|
|
6
|
+
from experiment_core.validation import ContractError, schema, validate_message
|
|
7
|
+
|
|
8
|
+
__all__ = ["ContractError", "schema", "validate_message"]
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""Advisory proposal sizes; never physical limits or execution authority."""
|
|
2
|
+
from copy import deepcopy
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
from experiment_core.records import digest, safe_id
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def validate(value):
|
|
9
|
+
if (not isinstance(value, dict) or set(value) != {'schema_version', 'knobs'}
|
|
10
|
+
or type(value['schema_version']) is not int or value['schema_version'] != 1
|
|
11
|
+
or not isinstance(value['knobs'], dict) or len(value['knobs']) > 128):
|
|
12
|
+
raise ValueError('Expected versioned per-knob adjustment guidance')
|
|
13
|
+
for name, item in value['knobs'].items():
|
|
14
|
+
safe_id(name)
|
|
15
|
+
if not isinstance(item, dict) or set(item) != {'kind', 'step', 'unit', 'reason'}:
|
|
16
|
+
raise ValueError('Guidance requires kind, step, unit and reason')
|
|
17
|
+
if item['kind'] not in ('absolute', 'relative'):
|
|
18
|
+
raise ValueError('Guidance kind must be absolute or relative')
|
|
19
|
+
if type(item['step']) not in (int, float) or not math.isfinite(item['step']) or item['step'] <= 0:
|
|
20
|
+
raise ValueError('Suggested step must be positive and finite')
|
|
21
|
+
for key, maximum in (('unit', 64), ('reason', 1024)):
|
|
22
|
+
text = item[key]
|
|
23
|
+
if (not isinstance(text, str) or not text.strip() or len(text.encode()) > maximum
|
|
24
|
+
or any(ord(c) < 32 or ord(c) == 127 for c in text)):
|
|
25
|
+
raise ValueError('Invalid guidance '+key)
|
|
26
|
+
if item['kind'] == 'relative' and item['unit'] != 'fraction':
|
|
27
|
+
raise ValueError('Relative advice uses a dimensionless fraction of the current magnitude')
|
|
28
|
+
return deepcopy(value)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def snapshot(value):
|
|
32
|
+
value = validate(value)
|
|
33
|
+
return dict(document=value, revision=digest(value), advisory_only=True,
|
|
34
|
+
interpretation='An initial proposal size, not a minimum, maximum, safety limit or permission')
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def instructions():
|
|
38
|
+
return ('Per-knob adjustment_guidance is advisory, not an execution bound. Start a local comparison '
|
|
39
|
+
'near the suggested initial step when it is appropriate to the current evidence. An absolute '
|
|
40
|
+
'step uses the stated physical unit; a relative step is a fraction of the magnitude of the '
|
|
41
|
+
'current parameter, not of its allowed range. Relative advice at zero does not define a step. '
|
|
42
|
+
'Use previous comparable responses, sensitivity, resolution, drift and recovery cost to choose '
|
|
43
|
+
'a smaller or larger step, and explain deviations in the decision reason. A permitted excursion '
|
|
44
|
+
'is not a recommended trial step: never select half the allowed span merely because it is allowed. '
|
|
45
|
+
'Characterization mode does not remove this distinction. Enlarge only when observations support '
|
|
46
|
+
'it; reduce after a large response or loss of the branch. Missing guidance means unknown, not '
|
|
47
|
+
'permission to use the range endpoint. Read current adjustment guidance before planning a new knob '
|
|
48
|
+
'trial if no current guidance snapshot was supplied. Advice alone never changes output, supported '
|
|
49
|
+
'capabilities, approved envelopes, physical limits, freshness requirements or foreground priority.')
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
"""Provider-neutral, versioned agent execution and evidence contracts.
|
|
2
|
+
|
|
3
|
+
Profiles select computation, never physical authority. Bundles retain provenance
|
|
4
|
+
without copying an entire conversation or silently truncating required evidence.
|
|
5
|
+
"""
|
|
6
|
+
import copy
|
|
7
|
+
import hashlib
|
|
8
|
+
import math
|
|
9
|
+
|
|
10
|
+
from experiment_core.records import canonical_json, digest, safe_id
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def text(value, name, limit=512, *, empty=False):
|
|
14
|
+
if (not isinstance(value, str) or (not value and not empty)
|
|
15
|
+
or len(value.encode()) > limit or any(ord(c) < 32 or ord(c)==127 for c in value)):
|
|
16
|
+
raise ValueError('Invalid ' + name)
|
|
17
|
+
return value
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def positive(value, name, maximum):
|
|
21
|
+
if type(value) not in (float, int) or not math.isfinite(value) or not 0 < value <= maximum:
|
|
22
|
+
raise ValueError('Invalid ' + name)
|
|
23
|
+
return value
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def execution_profile(value):
|
|
27
|
+
if not isinstance(value, dict):
|
|
28
|
+
raise ValueError('Execution profile must be a mapping')
|
|
29
|
+
defaults = dict(effort='', required_tools=[], required_modalities=['text'],
|
|
30
|
+
timeout_s=600., max_turns=8, max_input_bytes=32000)
|
|
31
|
+
if set(value) - {'provider', 'model', *defaults} or not {'provider', 'model'} <= value.keys():
|
|
32
|
+
raise ValueError('Profile requires provider/model and supported execution fields')
|
|
33
|
+
p = {**defaults, **copy.deepcopy(value)}
|
|
34
|
+
safe_id(p['provider'])
|
|
35
|
+
text(p['model'], 'exact model identifier')
|
|
36
|
+
text(p['effort'], 'effort', 80, empty=True)
|
|
37
|
+
positive(p['timeout_s'], 'task timeout', 86400)
|
|
38
|
+
for key, maximum in (('max_turns', 100), ('max_input_bytes', 262144)):
|
|
39
|
+
if type(p[key]) is not int or not 1 <= p[key] <= maximum:
|
|
40
|
+
raise ValueError('Invalid ' + key)
|
|
41
|
+
for key in ('required_tools', 'required_modalities'):
|
|
42
|
+
if (not isinstance(p[key], list) or len(p[key]) > 128 or any(not isinstance(v,str) for v in p[key])
|
|
43
|
+
or len(set(p[key])) != len(p[key])):
|
|
44
|
+
raise ValueError('Invalid ' + key)
|
|
45
|
+
for item in p[key]:
|
|
46
|
+
safe_id(item)
|
|
47
|
+
if set(p['required_modalities']) - {'text', 'image'}:
|
|
48
|
+
raise ValueError('Unsupported required modality')
|
|
49
|
+
return p
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
DIFFICULTIES = ('basic', 'standard', 'deep')
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def difficulty_profile(matrix, provider, difficulty):
|
|
56
|
+
"""Resolve an explicit routing table. Profile names carry no semantics."""
|
|
57
|
+
if difficulty not in DIFFICULTIES:
|
|
58
|
+
raise ValueError('Unknown task difficulty')
|
|
59
|
+
name = matrix.get(provider, {}).get(difficulty)
|
|
60
|
+
if not name:
|
|
61
|
+
raise ValueError('No profile for provider/difficulty: ' + provider + '/' + difficulty)
|
|
62
|
+
safe_id(name)
|
|
63
|
+
return name
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def execution_policy(value=None):
|
|
67
|
+
defaults = dict(mode='resident', task_profile='', escalation_profiles=[], provider_profiles={}, difficulty='')
|
|
68
|
+
if value is None:
|
|
69
|
+
return defaults
|
|
70
|
+
if not isinstance(value, dict) or set(value) - set(defaults):
|
|
71
|
+
raise ValueError('Invalid agent execution policy')
|
|
72
|
+
p = {**defaults, **copy.deepcopy(value)}
|
|
73
|
+
if p['mode'] not in ('resident', 'on_request', 'required'):
|
|
74
|
+
raise ValueError('Invalid agent execution mode')
|
|
75
|
+
if p['task_profile']:
|
|
76
|
+
safe_id(p['task_profile'])
|
|
77
|
+
if not isinstance(p['provider_profiles'], dict) or len(p['provider_profiles']) > 16:
|
|
78
|
+
raise ValueError('Invalid provider profiles')
|
|
79
|
+
for provider, profile in p['provider_profiles'].items():
|
|
80
|
+
safe_id(provider); safe_id(profile)
|
|
81
|
+
if p['difficulty'] not in ('', 'default', *DIFFICULTIES):
|
|
82
|
+
raise ValueError('Invalid card difficulty')
|
|
83
|
+
if sum(bool(p[k]) for k in ('provider_profiles', 'task_profile', 'difficulty')) > 1:
|
|
84
|
+
raise ValueError('Choose difficulty routing, inherited provider profiles or a fixed task profile')
|
|
85
|
+
if p['mode'] == 'required' and not (p['task_profile'] or p['provider_profiles'] or p['difficulty']):
|
|
86
|
+
raise ValueError('Required delegation needs a task profile')
|
|
87
|
+
if not isinstance(p['escalation_profiles'], list) or len(p['escalation_profiles']) > 32:
|
|
88
|
+
raise ValueError('Invalid escalation profiles')
|
|
89
|
+
for item in p['escalation_profiles']:
|
|
90
|
+
safe_id(item)
|
|
91
|
+
return p
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def choose_profile(policy, *, requested=False, override='', provider='', matrix=None, default_difficulty='standard'):
|
|
95
|
+
p = execution_policy(policy)
|
|
96
|
+
if p['mode'] == 'resident':
|
|
97
|
+
if override:
|
|
98
|
+
raise ValueError('This card does not allow delegation')
|
|
99
|
+
return None
|
|
100
|
+
if p['mode'] == 'on_request' and not requested:
|
|
101
|
+
return None
|
|
102
|
+
if p['difficulty']:
|
|
103
|
+
matrix = matrix or {}
|
|
104
|
+
if override:
|
|
105
|
+
allowed = {name for levels in matrix.values() for name in levels.values()} | set(p['escalation_profiles'])
|
|
106
|
+
if override not in allowed:
|
|
107
|
+
raise ValueError('Requested profile is not allowed by this card routing table')
|
|
108
|
+
return override
|
|
109
|
+
return difficulty_profile(matrix, provider, default_difficulty if p['difficulty']=='default' else p['difficulty'])
|
|
110
|
+
if not override and p['provider_profiles'] and provider not in p['provider_profiles']:
|
|
111
|
+
raise ValueError('No card task profile for the selected gateway provider: ' + (provider or 'unresolved'))
|
|
112
|
+
chosen = override or p['provider_profiles'].get(provider) or p['task_profile']
|
|
113
|
+
if not chosen or chosen not in [p['task_profile'], *p['provider_profiles'].values(), *p['escalation_profiles']]:
|
|
114
|
+
raise ValueError('Requested profile is not allowed by this card')
|
|
115
|
+
return chosen
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def instruction_bundle(common, task, *, revision, context, evidence, max_input_bytes=32000):
|
|
119
|
+
"""Fixed UTF-8 policy bytes plus bounded, separately hashed run evidence."""
|
|
120
|
+
instructions = common + b'\n\n' + task
|
|
121
|
+
instructions.decode('utf-8')
|
|
122
|
+
data = dict(context=copy.deepcopy(context), evidence=copy.deepcopy(evidence))
|
|
123
|
+
encoded = canonical_json(data).encode()
|
|
124
|
+
if len(encoded) > max_input_bytes:
|
|
125
|
+
raise ValueError('Task context exceeds its budget; select evidence or use artifact references')
|
|
126
|
+
return dict(schema_version=1, instruction_revision=revision,
|
|
127
|
+
instructions=instructions.decode(), instructions_sha256=hashlib.sha256(instructions).hexdigest(),
|
|
128
|
+
instructions_bytes=len(instructions), input=data, input_sha256=digest(data),
|
|
129
|
+
input_bytes=len(encoded), full_history_included=False)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class Conditions:
|
|
133
|
+
"""Persistable change detector: distinct fresh acquisitions, hysteresis and cooldown.
|
|
134
|
+
|
|
135
|
+
Missing/invalid data never clears an unresolved degradation or counts as a
|
|
136
|
+
new observation. The caller persists returned state, not a model conversation.
|
|
137
|
+
"""
|
|
138
|
+
def __init__(self, rules, state=None):
|
|
139
|
+
self.rules = self.validate(rules)
|
|
140
|
+
self.state = copy.deepcopy(state or {})
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def validate(rules):
|
|
144
|
+
if not isinstance(rules, list) or len(rules) > 32:
|
|
145
|
+
raise ValueError('Expected at most 32 monitor conditions')
|
|
146
|
+
ids = set()
|
|
147
|
+
for rule in rules:
|
|
148
|
+
required = {'id', 'metric', 'above', 'clear_below', 'persistence_s', 'cooldown_s', 'max_age_s'}
|
|
149
|
+
if not isinstance(rule, dict) or not required <= set(rule) <= required | {'baseline','repeat_s'}:
|
|
150
|
+
raise ValueError('Invalid monitor condition fields')
|
|
151
|
+
safe_id(rule['id']); text(rule['metric'], 'metric', 160)
|
|
152
|
+
if rule['id'] in ids:
|
|
153
|
+
raise ValueError('Duplicate monitor condition')
|
|
154
|
+
ids.add(rule['id'])
|
|
155
|
+
for name in ('above', 'clear_below', 'persistence_s', 'cooldown_s', 'max_age_s'):
|
|
156
|
+
v = rule[name]
|
|
157
|
+
if type(v) not in (int, float) or not math.isfinite(v):
|
|
158
|
+
raise ValueError('Monitor thresholds must be finite')
|
|
159
|
+
if (rule['clear_below'] > rule['above'] or rule['persistence_s'] < 0
|
|
160
|
+
or rule['cooldown_s'] < 0 or rule['max_age_s'] <= 0):
|
|
161
|
+
raise ValueError('Invalid monitor hysteresis/timing')
|
|
162
|
+
if 'repeat_s' in rule and (type(rule['repeat_s']) not in (int,float) or not math.isfinite(rule['repeat_s']) or rule['repeat_s']<=0):
|
|
163
|
+
raise ValueError('Monitor repeat interval must be finite and positive')
|
|
164
|
+
if 'baseline' in rule and (type(rule['baseline']) not in (int, float)
|
|
165
|
+
or not math.isfinite(rule['baseline']) or rule['baseline'] == 0):
|
|
166
|
+
raise ValueError('Relative-change baseline must be finite and nonzero')
|
|
167
|
+
return copy.deepcopy(rules)
|
|
168
|
+
|
|
169
|
+
def observe(self, metrics, now):
|
|
170
|
+
events = []
|
|
171
|
+
for rule in self.rules:
|
|
172
|
+
old = self.state.setdefault(rule['id'], {})
|
|
173
|
+
if old.get('rule') != digest(rule):
|
|
174
|
+
old.clear(); old['rule'] = digest(rule)
|
|
175
|
+
sample = metrics.get(rule['metric'], {})
|
|
176
|
+
value, timestamp = sample.get('value'), sample.get('acquired_unix_s')
|
|
177
|
+
if (sample.get('valid') is not True or type(value) not in (int, float)
|
|
178
|
+
or type(timestamp) not in (int, float) or not math.isfinite(value)
|
|
179
|
+
or not math.isfinite(timestamp) or not 0 <= now - timestamp <= rule['max_age_s']):
|
|
180
|
+
old.pop('since', None)
|
|
181
|
+
continue
|
|
182
|
+
if timestamp <= old.get('last_sample', -math.inf):
|
|
183
|
+
continue
|
|
184
|
+
if 'baseline' in rule:
|
|
185
|
+
value = abs(value-rule['baseline'])/abs(rule['baseline'])
|
|
186
|
+
if timestamp - old.get('last_sample', timestamp) > rule['max_age_s']:
|
|
187
|
+
old.pop('since', None)
|
|
188
|
+
old['last_sample'] = timestamp
|
|
189
|
+
if value <= rule['clear_below']:
|
|
190
|
+
old.pop('since', None); old['active'] = False
|
|
191
|
+
elif value > rule['above']:
|
|
192
|
+
old.setdefault('since', timestamp)
|
|
193
|
+
repeated=bool(old.get('active'))
|
|
194
|
+
due=not repeated or ('repeat_s' in rule and now-old.get('last_event',-math.inf)>=rule['repeat_s'])
|
|
195
|
+
if (due and timestamp - old['since'] >= rule['persistence_s']
|
|
196
|
+
and now - old.get('last_event', -math.inf) >= rule['cooldown_s']):
|
|
197
|
+
old.update(active=True, last_event=now)
|
|
198
|
+
events.append(dict(condition=rule['id'], metric=rule['metric'], value=value,
|
|
199
|
+
acquired_unix_s=timestamp, rule_revision=digest(rule), repeated=repeated))
|
|
200
|
+
return events
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Domain-neutral gateway/task relationships and result authorship.
|
|
2
|
+
|
|
3
|
+
Roles select responsibilities, not model price, provider, intelligence or physical
|
|
4
|
+
authority. A gateway is a communication parent; its liveness is not a task lease.
|
|
5
|
+
Domain hosts own permissions, evidence evaluation and effectful adoption.
|
|
6
|
+
"""
|
|
7
|
+
import copy
|
|
8
|
+
import math
|
|
9
|
+
|
|
10
|
+
from experiment_core.agent_contracts import execution_profile, text
|
|
11
|
+
from experiment_core.records import safe_id
|
|
12
|
+
|
|
13
|
+
VERSION = 'experiment.agent-roles/1'
|
|
14
|
+
ROLES = frozenset(('gateway', 'task'))
|
|
15
|
+
ADOPTION_MODES = frozenset(('report_only', 'human_confirmation', 'auto_within_approved'))
|
|
16
|
+
RESPONSIBILITIES = {
|
|
17
|
+
'gateway': ('receive_requests', 'dispatch_cards', 'inspect_progress', 'request_cancellation', 'relay_authored_results'),
|
|
18
|
+
'task': ('evaluate_evidence', 'propose_next_step', 'evaluate_outcome', 'author_explanation'),
|
|
19
|
+
}
|
|
20
|
+
ROLE_INSTRUCTIONS = {
|
|
21
|
+
'gateway': '''You are the conversation and card-dispatch gateway.
|
|
22
|
+
Handle requests to start, cancel or inspect registered operations, manage task
|
|
23
|
+
requests, and relay task-authored results with their author attribution.
|
|
24
|
+
Delegate domain analysis, optimization and explanations to the card task agent.
|
|
25
|
+
Unless the human explicitly selects another execution profile, omit the profile
|
|
26
|
+
override: inherit this gateway interface and use the card model/effort mapping.
|
|
27
|
+
Do not copy a provider or model from conversation history as a new override.
|
|
28
|
+
Do not replace its judgment or silently rewrite its final explanation. If an
|
|
29
|
+
explanation is unavailable, report that fact and preserve the recorded outcome.
|
|
30
|
+
A gateway disconnect does not cancel, replay or transfer an admitted task.
|
|
31
|
+
Model selection changes computation, never permissions or physical authority.''',
|
|
32
|
+
'task': '''You are an ordinary card task agent, including when the card is
|
|
33
|
+
monitoring, diagnosis or adjustment. Evaluate the supplied evidence, propose the
|
|
34
|
+
next permitted step, evaluate the outcome and author the final explanation from
|
|
35
|
+
host-confirmed results. The gateway is a communication endpoint, not an extra
|
|
36
|
+
scientific decision maker. Preserve the task's budget and authority boundaries;
|
|
37
|
+
do not infer approval from your model, role, parent or another agent's message.''',
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def role_contract(role):
|
|
42
|
+
"""Responsibilities are independent of provider, cost, effort and lifetime."""
|
|
43
|
+
if not isinstance(role, str) or role not in ROLES:
|
|
44
|
+
raise ValueError('Unknown agent role')
|
|
45
|
+
return dict(schema=VERSION, role=role, responsibilities=list(RESPONSIBILITIES[role]),
|
|
46
|
+
instructions=ROLE_INSTRUCTIONS[role])
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def assignment(role, profile):
|
|
50
|
+
"""Pin explicit computation settings without imposing a role-specific model."""
|
|
51
|
+
return dict(**role_contract(role), profile=profile_snapshot(profile))
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def profile_snapshot(profile):
|
|
55
|
+
"""Normalize computation plus optional registry provenance, without lookup."""
|
|
56
|
+
if not isinstance(profile, dict):
|
|
57
|
+
raise ValueError('Expected execution profile')
|
|
58
|
+
provenance = {k: profile[k] for k in ('id', 'registry_revision', 'profile_revision') if k in profile}
|
|
59
|
+
for key, value in provenance.items():
|
|
60
|
+
if key == 'id':
|
|
61
|
+
safe_id(value)
|
|
62
|
+
else:
|
|
63
|
+
text(value, key, 160)
|
|
64
|
+
computation = execution_profile({k: v for k, v in profile.items() if k not in provenance})
|
|
65
|
+
return {**provenance, **computation}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def relationship(task_id, *, gateway_id=None, parent_session=None, profile=None,
|
|
69
|
+
purpose='card_execution'):
|
|
70
|
+
safe_id(task_id)
|
|
71
|
+
if gateway_id is not None:
|
|
72
|
+
safe_id(gateway_id)
|
|
73
|
+
if gateway_id == task_id:
|
|
74
|
+
raise ValueError('A task cannot be its own gateway')
|
|
75
|
+
# Purpose is a registered host label, not another privileged agent role.
|
|
76
|
+
safe_id(purpose)
|
|
77
|
+
if parent_session is not None:
|
|
78
|
+
text(parent_session, 'parent session', 512)
|
|
79
|
+
if profile is not None:
|
|
80
|
+
profile = profile_snapshot(profile)
|
|
81
|
+
return dict(schema=VERSION, role='task', task_id=task_id, gateway_id=gateway_id,
|
|
82
|
+
parent_session=parent_session, purpose=purpose, profile=copy.deepcopy(profile),
|
|
83
|
+
gateway_liveness_required=False, decision_source_count=1, delegation_depth=1,
|
|
84
|
+
explanation_author='task', physical_authority='domain_host_grant_only')
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def adoption_mode(value):
|
|
88
|
+
if not isinstance(value, str) or value not in ADOPTION_MODES:
|
|
89
|
+
raise ValueError('Unknown adoption mode')
|
|
90
|
+
return value
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def adoption_disposition(mode, *, verified, within_approved_scope, requires_human=False):
|
|
94
|
+
"""Resolve eligibility; this grants no authority and performs no effects."""
|
|
95
|
+
adoption_mode(mode)
|
|
96
|
+
if any(type(v) is not bool for v in (verified, within_approved_scope, requires_human)):
|
|
97
|
+
raise ValueError('Adoption evidence must use explicit host booleans')
|
|
98
|
+
if not verified:
|
|
99
|
+
return 'unverified'
|
|
100
|
+
if mode == 'report_only':
|
|
101
|
+
return 'proposal_only'
|
|
102
|
+
if mode == 'human_confirmation' or requires_human or not within_approved_scope:
|
|
103
|
+
return 'awaiting_human'
|
|
104
|
+
return 'eligible_for_host_adoption'
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def explanation(text, *, task_id, session, profile, model_info):
|
|
108
|
+
safe_id(task_id)
|
|
109
|
+
if not isinstance(text, str) or not text.strip():
|
|
110
|
+
raise ValueError('Task explanation must be nonempty text')
|
|
111
|
+
profile = profile_snapshot(profile)
|
|
112
|
+
if not isinstance(session, str) or not session:
|
|
113
|
+
raise ValueError('Task explanation requires its author session')
|
|
114
|
+
if not isinstance(model_info, dict):
|
|
115
|
+
raise ValueError('Task explanation requires normalized adapter attestation')
|
|
116
|
+
if (model_info.get('confirmation') != 'server_reported' or
|
|
117
|
+
model_info.get('resolved_model') != profile.get('model') or
|
|
118
|
+
(profile.get('effort') and model_info.get('resolved_effort') != profile['effort'])):
|
|
119
|
+
raise ValueError('Explanation author profile is unconfirmed')
|
|
120
|
+
return dict(schema=VERSION, status='completed', text=text, task_id=task_id,
|
|
121
|
+
author=dict(role='task', profile=copy.deepcopy(profile), session=session,
|
|
122
|
+
model_info=copy.deepcopy(model_info)), relay='verbatim')
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def explanation_unavailable(error):
|
|
126
|
+
return dict(schema=VERSION, status='unavailable', error_type=type(error).__name__,
|
|
127
|
+
reason=str(error), relay='no_gateway_substitution')
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def decision_deadline(task_deadline, decision_deadline):
|
|
131
|
+
"""Delegation never extends a card's admitted computation deadline."""
|
|
132
|
+
if any(type(v) not in (int, float) or not math.isfinite(v)
|
|
133
|
+
for v in (task_deadline, decision_deadline)):
|
|
134
|
+
raise ValueError('Decision deadlines must be finite monotonic times')
|
|
135
|
+
return min(task_deadline, decision_deadline)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Owner-serialized background epochs and foreground generation fencing.
|
|
2
|
+
|
|
3
|
+
This coordinator performs no I/O or device writes. The application owner must
|
|
4
|
+
hold its existing dispatch mutex, persist checkpoint/intent records, and prove
|
|
5
|
+
restoration before calling ``complete``. A timeout never grants a handoff.
|
|
6
|
+
"""
|
|
7
|
+
from copy import deepcopy
|
|
8
|
+
import math
|
|
9
|
+
import uuid
|
|
10
|
+
|
|
11
|
+
from experiment_core.errors import Conflict, StaleContext
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BackgroundCoordinator:
|
|
15
|
+
def __init__(self, clock):
|
|
16
|
+
self.clock = clock
|
|
17
|
+
self.generation = 0
|
|
18
|
+
self.reservations = set()
|
|
19
|
+
self.epoch = None
|
|
20
|
+
self.last = None
|
|
21
|
+
|
|
22
|
+
def status(self):
|
|
23
|
+
return dict(generation=self.generation, reservations=sorted(self.reservations),
|
|
24
|
+
epoch=deepcopy(self.epoch), last=deepcopy(self.last),
|
|
25
|
+
foreground_ready=self.epoch is None)
|
|
26
|
+
|
|
27
|
+
def reserve(self, identifiers):
|
|
28
|
+
identifiers = set(identifiers)
|
|
29
|
+
if any(not isinstance(value, str) or not value or len(value) > 256 for value in identifiers):
|
|
30
|
+
raise ValueError('Bounded foreground request identities are required')
|
|
31
|
+
if identifiers - self.reservations:
|
|
32
|
+
self.generation += 1
|
|
33
|
+
if self.epoch:
|
|
34
|
+
self.returning('foreground', requested_at=self.clock())
|
|
35
|
+
self.reservations |= identifiers
|
|
36
|
+
return self.status()
|
|
37
|
+
|
|
38
|
+
def release(self, identifiers):
|
|
39
|
+
self.reservations -= set(identifiers)
|
|
40
|
+
return self.status()
|
|
41
|
+
|
|
42
|
+
def begin(self, expected_generation, context, checkpoint, timeout_s, identity, *, completion_mode="deadline"):
|
|
43
|
+
if completion_mode not in ("deadline", "verified_handover"):
|
|
44
|
+
raise ValueError("Unknown handoff completion mode")
|
|
45
|
+
if completion_mode == "verified_handover" and timeout_s is not None:
|
|
46
|
+
raise ValueError("Verified handover has no optical restoration deadline")
|
|
47
|
+
if completion_mode == 'deadline' and (type(timeout_s) not in (int, float) or not math.isfinite(timeout_s)
|
|
48
|
+
or not 0 < timeout_s <= 600):
|
|
49
|
+
raise ValueError('An explicit handoff deadline within 600 s is required')
|
|
50
|
+
if self.epoch or self.reservations:
|
|
51
|
+
raise Conflict('A foreground reservation or background epoch is active')
|
|
52
|
+
if expected_generation != self.generation:
|
|
53
|
+
raise StaleContext('Foreground generation changed')
|
|
54
|
+
self.epoch = dict(token=uuid.uuid4().hex, generation=self.generation,
|
|
55
|
+
context=deepcopy(context), checkpoint=deepcopy(checkpoint),
|
|
56
|
+
identity=deepcopy(identity), timeout_s=timeout_s, completion_mode=completion_mode,
|
|
57
|
+
state='active', started_at=self.clock())
|
|
58
|
+
return deepcopy(self.epoch)
|
|
59
|
+
|
|
60
|
+
def validate(self, token, context, *, restoring=False):
|
|
61
|
+
epoch = self.epoch
|
|
62
|
+
if epoch is None or token != epoch['token']:
|
|
63
|
+
raise StaleContext('Background epoch is absent or replaced')
|
|
64
|
+
if context != epoch['context']:
|
|
65
|
+
self.block('Owner, policy, target or branch changed')
|
|
66
|
+
raise StaleContext('Background checkpoint context changed')
|
|
67
|
+
if epoch['state'] == 'reconciliation_required':
|
|
68
|
+
raise Conflict(epoch['reason'])
|
|
69
|
+
if not restoring and (epoch['state'] != 'active' or epoch['generation'] != self.generation
|
|
70
|
+
or self.reservations):
|
|
71
|
+
raise StaleContext('Background proposals are fenced by foreground activity')
|
|
72
|
+
if restoring and self.clock() > epoch.get('return_deadline', math.inf):
|
|
73
|
+
self.block('Checkpoint restoration deadline exceeded')
|
|
74
|
+
raise Conflict('Checkpoint restoration deadline exceeded')
|
|
75
|
+
return deepcopy(epoch)
|
|
76
|
+
|
|
77
|
+
def returning(self, reason, *, requested_at=None):
|
|
78
|
+
if self.epoch and self.epoch['state'] == 'active':
|
|
79
|
+
now = self.clock()
|
|
80
|
+
self.epoch.update(state='returning', reason=reason, return_started_at=now,
|
|
81
|
+
requested_at=now if requested_at is None else requested_at)
|
|
82
|
+
if self.epoch.get('completion_mode') != 'verified_handover':
|
|
83
|
+
self.epoch['return_deadline'] = now + self.epoch['timeout_s']
|
|
84
|
+
return self.status()
|
|
85
|
+
|
|
86
|
+
def block(self, reason):
|
|
87
|
+
if self.epoch:
|
|
88
|
+
self.epoch.update(state='reconciliation_required', reason=str(reason))
|
|
89
|
+
return self.status()
|
|
90
|
+
|
|
91
|
+
def complete(self, token, context, receipt, *, adopted=False):
|
|
92
|
+
epoch = self.validate(token, context, restoring=not adopted)
|
|
93
|
+
if receipt.get('confirmed') is not True or receipt.get('response_ended') is not True:
|
|
94
|
+
raise Conflict('Confirmed readback and response cleanup are required')
|
|
95
|
+
self.last = dict(epoch, state='adopted' if adopted else 'restored',
|
|
96
|
+
completed_at=self.clock(), receipt=deepcopy(receipt))
|
|
97
|
+
self.epoch = None
|
|
98
|
+
return self.status()
|