websemantic 0.2.5__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.
- websemantic/__init__.py +3 -0
- websemantic/adapters/__init__.py +1 -0
- websemantic/adapters/lql.py +118 -0
- websemantic/adapters/pyrcel.py +104 -0
- websemantic/adapters/tls.py +130 -0
- websemantic/adapters/tls_outputs.py +70 -0
- websemantic/cli.py +592 -0
- websemantic/comparison.py +223 -0
- websemantic/conversation.py +44 -0
- websemantic/core/__init__.py +1 -0
- websemantic/core/validation.py +172 -0
- websemantic/environments.json +23 -0
- websemantic/gemini.py +222 -0
- websemantic/geography.py +223 -0
- websemantic/lql_comparison.py +138 -0
- websemantic/qualitative.py +60 -0
- websemantic/registry.py +45 -0
- websemantic/replay.py +68 -0
- websemantic/semantics.py +266 -0
- websemantic/session.py +271 -0
- websemantic/units.py +92 -0
- websemantic/web_research.py +146 -0
- websemantic-0.2.5.dist-info/METADATA +64 -0
- websemantic-0.2.5.dist-info/RECORD +28 -0
- websemantic-0.2.5.dist-info/WHEEL +5 -0
- websemantic-0.2.5.dist-info/entry_points.txt +2 -0
- websemantic-0.2.5.dist-info/licenses/LICENSE +135 -0
- websemantic-0.2.5.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Two independently validated scenarios, controlled experiments and qualified CSVs."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
import uuid
|
|
6
|
+
from copy import deepcopy
|
|
7
|
+
from dataclasses import asdict, replace
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from websemantic.core.validation import Issue, Parameter, ValidationResult, validate
|
|
12
|
+
from websemantic.units import fold
|
|
13
|
+
|
|
14
|
+
IDS = ('scenario_1', 'scenario_2')
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def validation(sessions, descriptor):
|
|
18
|
+
issues = []
|
|
19
|
+
refused = False
|
|
20
|
+
if set(sessions) != set(IDS):
|
|
21
|
+
issues.append(Issue('comparison', 'comparison_count', 'Exactement deux identifiants de scénario sont requis.'))
|
|
22
|
+
for label in IDS:
|
|
23
|
+
if label not in sessions:
|
|
24
|
+
issues.append(Issue(label, 'missing', 'Deux scénarios distincts sont requis.'))
|
|
25
|
+
continue
|
|
26
|
+
result = validate(sessions[label].scenario, descriptor)
|
|
27
|
+
refused = refused or result.decision == 'refuse'
|
|
28
|
+
issues.extend(replace(issue, field=f'{label}.{issue.field}') for issue in result.issues)
|
|
29
|
+
if sessions[label].pending_clarification or sessions[label].geographic_pending:
|
|
30
|
+
issues.append(Issue(label, 'clarification', 'Un choix de ce scénario reste à préciser.'))
|
|
31
|
+
for path in descriptor.get('comparison', {}).get('controlled_fields', []):
|
|
32
|
+
group, name = path.split('.')
|
|
33
|
+
records = [getattr(sessions[label].scenario, group).get(name) for label in IDS if label in sessions]
|
|
34
|
+
if len(records) == 2 and all(record and record.value is not None for record in records) and (records[0].value != records[1].value or records[0].unit != records[1].unit):
|
|
35
|
+
issues.append(Issue(path, 'comparison_control', 'La comparaison exige la même valeur dans les deux scénarios.'))
|
|
36
|
+
return ValidationResult('refuse' if refused else 'clarify' if issues else 'execute', tuple(issues))
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def apply(parent, request, parsed):
|
|
40
|
+
from websemantic.session import ClarificationNeeded, Session
|
|
41
|
+
|
|
42
|
+
if not parent.descriptor.get('comparison', {}).get('enabled'):
|
|
43
|
+
raise ClarificationNeeded('La comparaison n’est pas disponible pour cet environnement.')
|
|
44
|
+
if parsed.get('task') not in parent.descriptor['tasks']['supported'] + ['unsupported']:
|
|
45
|
+
raise ValueError('Tâche comparative non déclarée.')
|
|
46
|
+
if parsed.get('task') == 'unsupported':
|
|
47
|
+
parent.run_requested = False
|
|
48
|
+
parent.pending_clarification = parsed.get('message', 'Comparaison à préciser.')
|
|
49
|
+
raise ClarificationNeeded(parent.pending_clarification)
|
|
50
|
+
chunks = parsed.get('scenario_updates', [])
|
|
51
|
+
if not isinstance(chunks, list) or len(chunks) > 2:
|
|
52
|
+
raise ClarificationNeeded('Précisez exactement deux scénarios ; aucun changement appliqué.')
|
|
53
|
+
if any(not isinstance(chunk, dict) for chunk in chunks):
|
|
54
|
+
raise ValueError('Mises à jour des scénarios non conformes.')
|
|
55
|
+
if any(chunk.get('scenario') not in IDS for chunk in chunks) or len({chunk['scenario'] for chunk in chunks}) != len(chunks):
|
|
56
|
+
raise ClarificationNeeded('Identifiants de scénario absents, inconnus ou dupliqués.')
|
|
57
|
+
candidates = deepcopy(parent.scenarios) if parent.scenarios else {label: Session(parent.descriptor) for label in IDS}
|
|
58
|
+
task = parent.descriptor['tasks']['supported'][0]
|
|
59
|
+
common = parsed.get('updates', [])
|
|
60
|
+
explicitly_updated = {label: {item['field'] for item in common} for label in IDS}
|
|
61
|
+
from websemantic.conversation import validate_questions
|
|
62
|
+
|
|
63
|
+
questions = [(question, question.get('scenario', 'common')) for question in validate_questions(parsed.get('questions', []), parent.descriptor)]
|
|
64
|
+
for chunk in chunks:
|
|
65
|
+
for question in validate_questions(chunk.get('questions', []), parent.descriptor):
|
|
66
|
+
scope = question.get('scenario', chunk['scenario'])
|
|
67
|
+
if scope == 'common':
|
|
68
|
+
scope = chunk['scenario']
|
|
69
|
+
questions.append((question, scope))
|
|
70
|
+
if any(scope not in (*IDS, 'common') for _, scope in questions):
|
|
71
|
+
raise ValueError('Portée d’une question inconnue.')
|
|
72
|
+
for label in IDS:
|
|
73
|
+
child = candidates[label]
|
|
74
|
+
chunk = next((chunk for chunk in chunks if chunk['scenario'] == label), {})
|
|
75
|
+
# Common and specific values must not contradict each other silently.
|
|
76
|
+
specific = chunk.get('updates', [])
|
|
77
|
+
specific_paths = {item['field'] for item in specific}
|
|
78
|
+
explicitly_updated[label].update(specific_paths)
|
|
79
|
+
if specific_paths.intersection(item['field'] for item in common):
|
|
80
|
+
raise ClarificationNeeded('Un même paramètre est commun et spécifique. Précisez son affectation.')
|
|
81
|
+
child.apply(request, {'task': task, 'message': parsed.get('message', ''),
|
|
82
|
+
'updates': common + specific,
|
|
83
|
+
'questions': []})
|
|
84
|
+
for question, scope in questions:
|
|
85
|
+
if scope in (label, 'common'):
|
|
86
|
+
child.add_questions([question])
|
|
87
|
+
child.history[-1]['questions'] = [question for question, scope in questions if scope in (label, 'common')]
|
|
88
|
+
transfers = parsed.get('shared_from', [])
|
|
89
|
+
if not isinstance(transfers, list):
|
|
90
|
+
raise TypeError('Partage de valeurs non conforme.')
|
|
91
|
+
transferred = set()
|
|
92
|
+
for transfer in transfers:
|
|
93
|
+
source, target, path = transfer['source'], transfer['target'], transfer['field']
|
|
94
|
+
evidence = transfer['evidence']
|
|
95
|
+
if source not in IDS or target not in IDS or source == target or not evidence or evidence not in request:
|
|
96
|
+
raise ClarificationNeeded('L’égalité entre les deux scénarios doit être explicitement demandée.')
|
|
97
|
+
if not re.search(r'\b(?:meme|identique|egal|same|identical)\b', fold(evidence)) or re.search(r'\b(?:pas|sans|not)\b', fold(evidence)):
|
|
98
|
+
raise ClarificationNeeded('Citez une demande affirmative d’égalité entre les deux scénarios.')
|
|
99
|
+
if (target, path) in transferred:
|
|
100
|
+
raise ClarificationNeeded('Partage dupliqué pour un paramètre.')
|
|
101
|
+
if (source, path) in transferred:
|
|
102
|
+
raise ClarificationNeeded('Une égalité circulaire doit être reformulée avec une seule source.')
|
|
103
|
+
transferred.add((target, path))
|
|
104
|
+
group, name = path.split('.')
|
|
105
|
+
if group not in ('inputs', 'experiment') or name not in parent.descriptor[group]:
|
|
106
|
+
raise ValueError('Paramètre partagé inconnu.')
|
|
107
|
+
record = getattr(candidates[source].scenario, group).get(name)
|
|
108
|
+
if not record or record.value is None:
|
|
109
|
+
raise ClarificationNeeded('La valeur à partager doit d’abord être définie dans le scénario source.')
|
|
110
|
+
current = getattr(candidates[target].scenario, group).get(name)
|
|
111
|
+
if path in explicitly_updated[target] and current and current.origin == 'provided' and (current.value != record.value or current.unit != record.unit):
|
|
112
|
+
raise ClarificationNeeded('Une valeur explicite contredit l’égalité demandée entre les scénarios.')
|
|
113
|
+
inherited = Parameter(record.value, record.unit, 'assumption', evidence,
|
|
114
|
+
f'Égalité explicitement demandée depuis {source}: {evidence}. '
|
|
115
|
+
f'Origine source={record.origin}; source={record.source or record.evidence}.',
|
|
116
|
+
accepted=record.origin == 'provided' or record.accepted)
|
|
117
|
+
getattr(candidates[target].scenario, group)[name] = inherited
|
|
118
|
+
candidates[target].run_requested = True
|
|
119
|
+
candidates[target].questions = [item for item in candidates[target].questions if path not in item['fields']]
|
|
120
|
+
candidates[target].pending_clarification = None
|
|
121
|
+
candidates[target].refresh_questions()
|
|
122
|
+
parent.scenarios = candidates
|
|
123
|
+
parent.run_requested = parent.run_requested or any(child.run_requested for child in candidates.values())
|
|
124
|
+
parent.history.append({'user': request, 'assistant': parsed.get('message', ''),
|
|
125
|
+
'comparison': True, 'updates': common, 'questions': parsed.get('questions', []),
|
|
126
|
+
'scenario_updates': chunks, 'shared_from': transfers})
|
|
127
|
+
parent.refresh_comparison()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def run(sessions, descriptor, workspace, output_root):
|
|
131
|
+
import pandas as pd
|
|
132
|
+
from rdflib import Graph, Literal, URIRef
|
|
133
|
+
from rdflib.namespace import RDF
|
|
134
|
+
|
|
135
|
+
from websemantic.registry import execute
|
|
136
|
+
from websemantic.semantics import PROV, WS
|
|
137
|
+
|
|
138
|
+
if validation(sessions, descriptor).decision != 'execute':
|
|
139
|
+
raise ValueError('Les deux scénarios doivent être complets, validés et comparables avant tout calcul.')
|
|
140
|
+
root = Path(output_root or Path(workspace) / 'runs')
|
|
141
|
+
target = root / (datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + '-comparison-' + uuid.uuid4().hex[:8])
|
|
142
|
+
target.mkdir(parents=True)
|
|
143
|
+
paths, medians, manifests = {}, {}, {}
|
|
144
|
+
try:
|
|
145
|
+
for label in IDS:
|
|
146
|
+
paths[label], medians[label] = execute(sessions[label].scenario, descriptor, workspace, target / label)
|
|
147
|
+
manifests[label] = json.loads((paths[label] / 'manifest.json').read_text(encoding='utf-8'))
|
|
148
|
+
tables = manifests[IDS[0]]['outputs']
|
|
149
|
+
if set(tables) != set(manifests[IDS[1]]['outputs']):
|
|
150
|
+
raise ValueError('Les contrats de sortie des deux scénarios ne correspondent pas.')
|
|
151
|
+
qualification = deepcopy(manifests[IDS[0]]['output_qualification'])
|
|
152
|
+
graph = Graph()
|
|
153
|
+
graph.bind('wsem', WS)
|
|
154
|
+
graph.bind('prov', PROV)
|
|
155
|
+
activity = URIRef(target.resolve().as_uri() + '#comparison')
|
|
156
|
+
graph.add((activity, RDF.type, PROV.Activity))
|
|
157
|
+
graph.add((activity, RDF.type, WS.ComparisonActivity))
|
|
158
|
+
graph.add((activity, WS.method, Literal('Separate executions; concatenate tables, no pooled statistics.')))
|
|
159
|
+
for label in IDS:
|
|
160
|
+
# Read through the filesystem: URL loading drops the host on Windows UNC paths.
|
|
161
|
+
source = paths[label] / 'semantics.ttl'
|
|
162
|
+
graph.parse(data=source.read_text(encoding='utf-8'), format='turtle',
|
|
163
|
+
publicID=source.resolve().as_uri())
|
|
164
|
+
for table in tables:
|
|
165
|
+
frames = []
|
|
166
|
+
for label in IDS:
|
|
167
|
+
metadata = manifests[label]['output_qualification']['tables'][table]
|
|
168
|
+
if metadata['columns'] != manifests[IDS[0]]['output_qualification']['tables'][table]['columns']:
|
|
169
|
+
raise ValueError('Unités ou définitions incompatibles ; comparaison interrompue.')
|
|
170
|
+
frame = pd.read_csv(paths[label] / (table + '.csv'))
|
|
171
|
+
if 'scenario' in frame:
|
|
172
|
+
raise ValueError('Une colonne scenario existe déjà dans le backend.')
|
|
173
|
+
frame.insert(0, 'scenario', label)
|
|
174
|
+
frames.append(frame)
|
|
175
|
+
combined = pd.concat(frames, ignore_index=True)
|
|
176
|
+
combined.to_csv(target / (table + '.csv'), index=False)
|
|
177
|
+
info = qualification['tables'][table]
|
|
178
|
+
info['rows'] = len(combined)
|
|
179
|
+
info['columns'] = {'scenario': {'unit': None, 'quantity': 'scenario_identifier',
|
|
180
|
+
'meaning': 'scenario_1 ou scenario_2 ; les réalisations restent distinctes par scénario.'}, **info['columns']}
|
|
181
|
+
info['aggregation'] = 'Concaténation par scénario, sans moyenne entre scénarios. ' + info['aggregation']
|
|
182
|
+
output = URIRef((target / (table + '.csv')).resolve().as_uri())
|
|
183
|
+
graph.add((output, RDF.type, PROV.Entity))
|
|
184
|
+
graph.add((output, RDF.type, WS.SimulationOutput))
|
|
185
|
+
graph.add((output, PROV.wasGeneratedBy, activity))
|
|
186
|
+
for name, column in info['columns'].items():
|
|
187
|
+
entity = URIRef(str(output) + '#column-' + name)
|
|
188
|
+
graph.add((output, WS.hasColumn, entity))
|
|
189
|
+
graph.add((entity, WS.meaning, Literal(column['meaning'], lang='fr')))
|
|
190
|
+
if column.get('quantity'):
|
|
191
|
+
graph.add((entity, WS.quantity, Literal(column['quantity'])))
|
|
192
|
+
if column.get('unit'):
|
|
193
|
+
graph.add((entity, WS.unitSymbol, Literal(column['unit'])))
|
|
194
|
+
for label in IDS:
|
|
195
|
+
child = URIRef((paths[label] / (table + '.csv')).resolve().as_uri())
|
|
196
|
+
graph.add((child, RDF.type, PROV.Entity))
|
|
197
|
+
graph.add((child, WS.scenarioIdentifier, Literal(label)))
|
|
198
|
+
for original in list(graph.subjects(PROV.atLocation, child)):
|
|
199
|
+
graph.add((child, PROV.wasDerivedFrom, original))
|
|
200
|
+
for native_activity in graph.objects(original, PROV.wasGeneratedBy):
|
|
201
|
+
graph.add((child, PROV.wasGeneratedBy, native_activity))
|
|
202
|
+
graph.add((activity, PROV.used, child))
|
|
203
|
+
graph.add((output, PROV.wasDerivedFrom, child))
|
|
204
|
+
differences = {}
|
|
205
|
+
for indicator in descriptor['comparison']['indicators']:
|
|
206
|
+
a, b = medians[IDS[0]][indicator], medians[IDS[1]][indicator]
|
|
207
|
+
differences[indicator] = {'scenario_1': a, 'scenario_2': b, 'difference_1_minus_2': a-b,
|
|
208
|
+
'relative_percent_vs_2': (100*(a-b)/b) if b else None}
|
|
209
|
+
report = {'comparison': True, 'scenario_medians': medians, 'differences': differences,
|
|
210
|
+
'method': 'Differences between scenario medians; common seeds do not guarantee identical random draws after configuration changes.'}
|
|
211
|
+
manifest = {'mode': 'comparison', 'status': 'complete', 'software': descriptor['software'],
|
|
212
|
+
'scenarios': {label: asdict(sessions[label].scenario) for label in IDS},
|
|
213
|
+
'scenario_runs': {label: str(paths[label].resolve()) for label in IDS},
|
|
214
|
+
'controlled_fields': descriptor['comparison']['controlled_fields'],
|
|
215
|
+
'outputs': tables, 'output_qualification': qualification,
|
|
216
|
+
'comparison': report, 'nature': descriptor['nature']}
|
|
217
|
+
(target / 'comparison.json').write_text(json.dumps(report, ensure_ascii=False, indent=2, allow_nan=False), encoding='utf-8')
|
|
218
|
+
graph.serialize(target / 'semantics.ttl', format='turtle')
|
|
219
|
+
(target / 'manifest.json').write_text(json.dumps(manifest, ensure_ascii=False, indent=2, allow_nan=False), encoding='utf-8')
|
|
220
|
+
except Exception:
|
|
221
|
+
(target / 'INCOMPLETE.txt').write_text('Comparaison incomplète ; ne pas interpréter ces fichiers comme un résultat validé.', encoding='utf-8')
|
|
222
|
+
raise
|
|
223
|
+
return target, report
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""Locally controlled conversation actions proposed by the interpreter."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from websemantic.units import fold
|
|
6
|
+
|
|
7
|
+
ACTIONS = {"propose", "accept", "details", "explain", "suggest", "formulas", "quit"}
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def validate_questions(questions, descriptor):
|
|
11
|
+
fields = {f'{group}.{name}' for group in ('inputs', 'experiment') for name in descriptor.get(group, {})}
|
|
12
|
+
if not isinstance(questions, list) or len(questions) > 2:
|
|
13
|
+
raise ValueError('Une ou deux questions ciblées sont attendues.')
|
|
14
|
+
for question in questions:
|
|
15
|
+
if (not isinstance(question, dict) or not isinstance(question.get('question'), str)
|
|
16
|
+
or not question['question'].strip() or type(question.get('blocking')) is not bool
|
|
17
|
+
or not isinstance(question.get('fields'), list) or not question['fields']
|
|
18
|
+
or question.get('scenario', 'common') not in ('common', 'scenario_1', 'scenario_2')
|
|
19
|
+
or any(field not in fields for field in question['fields'])):
|
|
20
|
+
raise ValueError('Question de clarification non conforme.')
|
|
21
|
+
return questions
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def requested_actions(parsed):
|
|
25
|
+
actions = parsed.get("actions", [])
|
|
26
|
+
if not isinstance(actions, list) or any(action not in ACTIONS for action in actions):
|
|
27
|
+
raise ValueError("Action conversationnelle non reconnue.")
|
|
28
|
+
return set(actions)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def explicit_consent(request, evidence):
|
|
32
|
+
if not isinstance(evidence, str) or not evidence or evidence not in request:
|
|
33
|
+
return False
|
|
34
|
+
text = fold(request)
|
|
35
|
+
if "?" in text or re.search(r"\bne\b.*?\bpas\b|\bn['’].*?\bpas\b|\b(?:sans|refuse)\b", text):
|
|
36
|
+
return False
|
|
37
|
+
if re.fullmatch(r"\s*(?:prends|utilise|accepte)\s+le\s+reste\s+par\s+defaut\s*[.!]?\s*", fold(evidence)):
|
|
38
|
+
return True
|
|
39
|
+
return bool(re.fullmatch(
|
|
40
|
+
r"\s*(?:j['’]accepte|je valide|accepte|valide|prends|utilise)\s+"
|
|
41
|
+
r"(?:(?:toutes?|tous)\s+)?(?:les|ces|des)\s+"
|
|
42
|
+
r"(?:hypotheses(?: proposees)?|parametres(?: proposes)?|valeurs(?: proposees| par defaut| moyennes| du profil)?)"
|
|
43
|
+
r"(?:\s+pour les deux scenarios)?"
|
|
44
|
+
r"(?:\s*(?:[,;]|et|puis)\s*(?:lance le calcul|calcule|simule))?\s*[.!]?\s*", fold(evidence)))
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Generic core: semantic state, parser, clarification, selection, gate, run, annotation. Not implemented yet."""
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""First deterministic gate. Canonical values only; no LLM or unit conversion.
|
|
2
|
+
|
|
3
|
+
Evidence presence is checked, not its semantic truth. SHACL and relational
|
|
4
|
+
task-suitability rules are intentionally not implemented by this first slice.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import date
|
|
9
|
+
from math import isfinite
|
|
10
|
+
from operator import ge, gt, le, lt
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Parameter:
|
|
16
|
+
value: Any = None
|
|
17
|
+
unit: str | None = None
|
|
18
|
+
origin: str = "missing"
|
|
19
|
+
evidence: str | None = None
|
|
20
|
+
source: str | None = None
|
|
21
|
+
accepted: bool = False
|
|
22
|
+
conflicts: tuple[Any, ...] = ()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Scenario:
|
|
27
|
+
request: str
|
|
28
|
+
task: str
|
|
29
|
+
inputs: dict[str, Parameter] = field(default_factory=dict)
|
|
30
|
+
experiment: dict[str, Parameter] = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Issue:
|
|
35
|
+
field: str
|
|
36
|
+
code: str
|
|
37
|
+
message: str
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class ValidationResult:
|
|
42
|
+
decision: str
|
|
43
|
+
issues: tuple[Issue, ...]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate(scenario: Scenario, descriptor: dict) -> ValidationResult:
|
|
47
|
+
"""Return execute/clarify/refuse, never fill a missing field.
|
|
48
|
+
|
|
49
|
+
Tasks must match a descriptor task exactly in this initial implementation.
|
|
50
|
+
Unknown descriptor types fail closed. Descriptor review is still required.
|
|
51
|
+
"""
|
|
52
|
+
tasks = descriptor.get("tasks", {})
|
|
53
|
+
if scenario.task not in tasks.get("supported", []):
|
|
54
|
+
return ValidationResult(
|
|
55
|
+
"refuse",
|
|
56
|
+
(Issue("task", "unsupported_task", "Task is not declared as supported."),),
|
|
57
|
+
)
|
|
58
|
+
issues = []
|
|
59
|
+
for group in ("inputs", "experiment"):
|
|
60
|
+
supplied = getattr(scenario, group)
|
|
61
|
+
specs = descriptor.get(group, {})
|
|
62
|
+
for name in sorted(supplied.keys() - specs.keys()):
|
|
63
|
+
issues.append(
|
|
64
|
+
Issue(
|
|
65
|
+
f"{group}.{name}",
|
|
66
|
+
"unknown_field",
|
|
67
|
+
"Field is not declared in the descriptor.",
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
for name, spec in specs.items():
|
|
71
|
+
path = f"{group}.{name}"
|
|
72
|
+
record = supplied.get(name, Parameter())
|
|
73
|
+
value = record.value
|
|
74
|
+
if type(record.accepted) is not bool:
|
|
75
|
+
issues.append(Issue(path, "acceptance_type", "Acceptance must be a JSON boolean."))
|
|
76
|
+
continue
|
|
77
|
+
if record.conflicts:
|
|
78
|
+
issues.append(Issue(path, "conflict", "Resolve conflicting values."))
|
|
79
|
+
if value is None:
|
|
80
|
+
issues.append(
|
|
81
|
+
Issue(
|
|
82
|
+
path,
|
|
83
|
+
"missing",
|
|
84
|
+
"An explicit value or accepted assumption is required.",
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
continue
|
|
88
|
+
if record.origin == "provided":
|
|
89
|
+
if not record.evidence or record.evidence not in scenario.request:
|
|
90
|
+
issues.append(
|
|
91
|
+
Issue(
|
|
92
|
+
path,
|
|
93
|
+
"evidence",
|
|
94
|
+
"Provide an exact evidence span from the request.",
|
|
95
|
+
)
|
|
96
|
+
)
|
|
97
|
+
elif record.origin in ("default", "assumption"):
|
|
98
|
+
if not record.accepted or not record.source:
|
|
99
|
+
issues.append(
|
|
100
|
+
Issue(
|
|
101
|
+
path,
|
|
102
|
+
"unaccepted_assumption",
|
|
103
|
+
"Assumptions need a source and explicit acceptance.",
|
|
104
|
+
)
|
|
105
|
+
)
|
|
106
|
+
else:
|
|
107
|
+
issues.append(
|
|
108
|
+
Issue(
|
|
109
|
+
path,
|
|
110
|
+
"unsupported_origin",
|
|
111
|
+
"Origin is unsupported by this initial gate.",
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
if spec.get("unit") != record.unit:
|
|
115
|
+
issues.append(
|
|
116
|
+
Issue(
|
|
117
|
+
path,
|
|
118
|
+
"unit",
|
|
119
|
+
"Use the canonical descriptor unit; no conversion is performed.",
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
kind = spec.get("type")
|
|
123
|
+
valid_type = False
|
|
124
|
+
if kind == "int":
|
|
125
|
+
valid_type = type(value) is int
|
|
126
|
+
elif kind == "float":
|
|
127
|
+
valid_type = type(value) in (int, float)
|
|
128
|
+
elif kind == "category":
|
|
129
|
+
valid_type = isinstance(value, str)
|
|
130
|
+
elif kind == "date":
|
|
131
|
+
valid_type = isinstance(value, str)
|
|
132
|
+
if valid_type:
|
|
133
|
+
try:
|
|
134
|
+
valid_type = date.fromisoformat(value).isoformat() == value
|
|
135
|
+
except ValueError:
|
|
136
|
+
valid_type = False
|
|
137
|
+
if not valid_type:
|
|
138
|
+
issues.append(
|
|
139
|
+
Issue(path, "type", f"Invalid value for descriptor type {kind!r}.")
|
|
140
|
+
)
|
|
141
|
+
continue
|
|
142
|
+
if kind in ("int", "float"):
|
|
143
|
+
try:
|
|
144
|
+
finite = isfinite(value)
|
|
145
|
+
except OverflowError:
|
|
146
|
+
finite = False
|
|
147
|
+
if not finite:
|
|
148
|
+
issues.append(
|
|
149
|
+
Issue(path, "finite", "Numeric values must be finite.")
|
|
150
|
+
)
|
|
151
|
+
continue
|
|
152
|
+
bounds = spec.get("bounds", {})
|
|
153
|
+
checks = {
|
|
154
|
+
"min": ge,
|
|
155
|
+
"max": le,
|
|
156
|
+
"min_exclusive": gt,
|
|
157
|
+
"max_exclusive": lt,
|
|
158
|
+
}
|
|
159
|
+
for key, check in checks.items():
|
|
160
|
+
if key in bounds and not check(value, bounds[key]):
|
|
161
|
+
issues.append(
|
|
162
|
+
Issue(
|
|
163
|
+
path,
|
|
164
|
+
"bounds",
|
|
165
|
+
f"Violates {key}={bounds[key]} ({bounds.get('authority', 'unspecified')}).",
|
|
166
|
+
)
|
|
167
|
+
)
|
|
168
|
+
elif kind == "category" and value not in spec.get("values", []):
|
|
169
|
+
issues.append(
|
|
170
|
+
Issue(path, "category", "Value is not an allowed category.")
|
|
171
|
+
)
|
|
172
|
+
return ValidationResult("clarify" if issues else "execute", tuple(issues))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"id": "tls",
|
|
4
|
+
"namespace": "websemantic.tls",
|
|
5
|
+
"definition": "Tunnel Load Simulator : demande électrique des tunnels routiers",
|
|
6
|
+
"descriptor": "tls",
|
|
7
|
+
"available": true
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"id": "lql",
|
|
11
|
+
"namespace": "websemantic.lql",
|
|
12
|
+
"definition": "LQL-Equiv : équivalences radiobiologiques",
|
|
13
|
+
"descriptor": "lqlequiv",
|
|
14
|
+
"available": true
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"id": "pyrcel",
|
|
18
|
+
"namespace": "websemantic.pyrcel",
|
|
19
|
+
"definition": "pyrcel : activation des aérosols dans une parcelle ascendante",
|
|
20
|
+
"descriptor": "pyrcel",
|
|
21
|
+
"available": true
|
|
22
|
+
}
|
|
23
|
+
]
|