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.
@@ -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
+ ]