devagent-ignition 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. devagent_hmi.py +471 -0
  2. devagent_ignition-0.1.0.dist-info/METADATA +204 -0
  3. devagent_ignition-0.1.0.dist-info/RECORD +133 -0
  4. devagent_ignition-0.1.0.dist-info/WHEEL +4 -0
  5. devagent_ignition-0.1.0.dist-info/entry_points.txt +2 -0
  6. devagent_spatial/__init__.py +1 -0
  7. devagent_spatial/_resources/README.md +187 -0
  8. devagent_spatial/_resources/RELEASE_MANIFEST.json +312 -0
  9. devagent_spatial/_resources/SHA256SUMS.txt +270 -0
  10. devagent_spatial/_resources/SOURCE_PROVENANCE.json +186 -0
  11. devagent_spatial/_resources/assets/agent.js +102 -0
  12. devagent_spatial/_resources/assets/creator.css +247 -0
  13. devagent_spatial/_resources/assets/creator_2d.mjs +16 -0
  14. devagent_spatial/_resources/assets/creator_original_r9.mjs +58 -0
  15. devagent_spatial/_resources/assets/creator_original_v8.mjs +120 -0
  16. devagent_spatial/_resources/assets/creator_r9_diagnostics.mjs +53 -0
  17. devagent_spatial/_resources/assets/creator_scene.mjs +128 -0
  18. devagent_spatial/_resources/assets/creator_ui.mjs +60 -0
  19. devagent_spatial/_resources/assets/customer_documents.js +110 -0
  20. devagent_spatial/_resources/assets/equipment_controls.mjs +27 -0
  21. devagent_spatial/_resources/assets/facility2d.css +24 -0
  22. devagent_spatial/_resources/assets/facility2d_app.mjs +45 -0
  23. devagent_spatial/_resources/assets/facility2d_core.mjs +114 -0
  24. devagent_spatial/_resources/assets/facility2d_simulation.mjs +167 -0
  25. devagent_spatial/_resources/assets/facility2d_viewer.html +7 -0
  26. devagent_spatial/_resources/assets/fallback_scene.js +17 -0
  27. devagent_spatial/_resources/assets/flow_geometry.mjs +218 -0
  28. devagent_spatial/_resources/assets/harness.js +78 -0
  29. devagent_spatial/_resources/assets/live_design_review.js +129 -0
  30. devagent_spatial/_resources/assets/operations_ui.js +305 -0
  31. devagent_spatial/_resources/assets/operator_viewer.html +64 -0
  32. devagent_spatial/_resources/assets/project_package.js +52 -0
  33. devagent_spatial/_resources/assets/r9_transport.mjs +70 -0
  34. devagent_spatial/_resources/assets/runtime_feed.js +30 -0
  35. devagent_spatial/_resources/assets/studio.html +139 -0
  36. devagent_spatial/_resources/assets/style.css +191 -0
  37. devagent_spatial/_resources/assets/test_workspace.html +195 -0
  38. devagent_spatial/_resources/assets/three_scene.js +442 -0
  39. devagent_spatial/_resources/assets/unified_engineer.js +101 -0
  40. devagent_spatial/_resources/assets/v076_fat_ui.js +40 -0
  41. devagent_spatial/_resources/assets/v078_command_ui.js +44 -0
  42. devagent_spatial/_resources/assets/viewer.html +45 -0
  43. devagent_spatial/_resources/assets/viewer.js +112 -0
  44. devagent_spatial/_resources/design-system.json +130 -0
  45. devagent_spatial/_resources/examples/CV101-command-contract-PENDING.json +30 -0
  46. devagent_spatial/_resources/examples/cv101_guided.json +272 -0
  47. devagent_spatial/_resources/examples/make_examples.py +36 -0
  48. devagent_spatial/_resources/examples/single_floor.json +444 -0
  49. devagent_spatial/_resources/examples/v0771_training_wizard.json +11 -0
  50. devagent_spatial/_resources/examples/warehouse-3floor-standard-2d.json +708 -0
  51. devagent_spatial/_resources/examples/warehouse-operations-synthetic.json +1186 -0
  52. devagent_spatial/_resources/examples/warehouse-v07-lab-bindings-PENDING.json +372 -0
  53. devagent_spatial/_resources/examples/warehouse.json +708 -0
  54. devagent_spatial/_resources/examples/water.json +288 -0
  55. devagent_spatial/_resources/reference/r9-golden/REFERENCE_SHA256.json +13 -0
  56. devagent_spatial/_resources/reference/r9-golden/web/app.mjs +745 -0
  57. devagent_spatial/_resources/reference/r9-golden/web/index.html +213 -0
  58. devagent_spatial/_resources/reference/r9-golden/web/lab_panel.mjs +53 -0
  59. devagent_spatial/_resources/reference/r9-golden/web/model.mjs +367 -0
  60. devagent_spatial/_resources/reference/r9-golden/web/state_adapter.mjs +78 -0
  61. devagent_spatial/_resources/reference/r9-golden/web/styles.css +76 -0
  62. devagent_spatial/_resources/reference/r9-original/README_R9.md +27 -0
  63. devagent_spatial/_resources/reference/r9-original/SHA256SUMS.txt +63 -0
  64. devagent_spatial/_resources/reference/r9-original/SNAPSHOT_SHA256SUMS.txt +16 -0
  65. devagent_spatial/_resources/reference/r9-original/flow_routes.json +527 -0
  66. devagent_spatial/_resources/reference/r9-original/flow_sim.py +117 -0
  67. devagent_spatial/_resources/reference/r9-original/lab_control.py +73 -0
  68. devagent_spatial/_resources/reference/r9-original/web/app.mjs +745 -0
  69. devagent_spatial/_resources/reference/r9-original/web/index.html +213 -0
  70. devagent_spatial/_resources/reference/r9-original/web/lab_panel.mjs +53 -0
  71. devagent_spatial/_resources/reference/r9-original/web/model.mjs +367 -0
  72. devagent_spatial/_resources/reference/r9-original/web/state_adapter.mjs +78 -0
  73. devagent_spatial/_resources/reference/r9-original/web/styles.css +76 -0
  74. devagent_spatial/_resources/reference/r9-original/web/vendor/LICENSE-three.txt +21 -0
  75. devagent_spatial/_resources/reference/r9-original/web/vendor/SHA256SUMS.txt +4 -0
  76. devagent_spatial/_resources/reference/r9-original/web/vendor/addons/controls/OrbitControls.js +1860 -0
  77. devagent_spatial/_resources/reference/r9-original/web/vendor/three.core.js +58773 -0
  78. devagent_spatial/_resources/reference/r9-original/web/vendor/three.module.js +18251 -0
  79. devagent_spatial/_resources/reference/v8-original/README.md +36 -0
  80. devagent_spatial/_resources/reference/v8-original/SHA256SUMS.txt +12 -0
  81. devagent_spatial/_resources/reference/v8-original/model.py +124 -0
  82. devagent_spatial/_resources/reference/v8-original/run_demo.sh +4 -0
  83. devagent_spatial/_resources/reference/v8-original/server.py +100 -0
  84. devagent_spatial/_resources/reference/v8-original/tests/browser_smoke.py +50 -0
  85. devagent_spatial/_resources/reference/v8-original/tests/test_v8.py +144 -0
  86. devagent_spatial/_resources/reference/v8-original/topology.json +24 -0
  87. devagent_spatial/_resources/reference/v8-original/web/app.js +114 -0
  88. devagent_spatial/_resources/reference/v8-original/web/index.html +22 -0
  89. devagent_spatial/_resources/reference/v8-original/web/simulator.html +1 -0
  90. devagent_spatial/_resources/reference/v8-original/web/simulator.js +6 -0
  91. devagent_spatial/_resources/reference/v8-original/web/style.css +16 -0
  92. devagent_spatial/_resources/sample_customer_inputs/Plant_Equipment_Register.csv +4 -0
  93. devagent_spatial/_resources/sample_customer_inputs/Plant_Flow_Requirements.txt +5 -0
  94. devagent_spatial/_resources/sample_customer_inputs/Raw_PLC_Tag_Export.csv +4 -0
  95. devagent_spatial/_resources/templates/warehouse_connections.csv +12 -0
  96. devagent_spatial/_resources/templates/warehouse_equipment.csv +21 -0
  97. devagent_spatial/_resources/templates/warehouse_signals.csv +34 -0
  98. devagent_spatial/_resources/templates/water_connections.csv +7 -0
  99. devagent_spatial/_resources/templates/water_equipment.csv +10 -0
  100. devagent_spatial/_resources/templates/water_signals.csv +12 -0
  101. devagent_spatial/agent_harness.py +299 -0
  102. devagent_spatial/agent_runtime.py +314 -0
  103. devagent_spatial/contract_opc_fat.py +216 -0
  104. devagent_spatial/contract_opc_source.py +265 -0
  105. devagent_spatial/control_intents.py +38 -0
  106. devagent_spatial/customer_intake.py +235 -0
  107. devagent_spatial/engineering_expertise.py +114 -0
  108. devagent_spatial/generator.py +201 -0
  109. devagent_spatial/ignition_native.py +234 -0
  110. devagent_spatial/ignition_project.py +144 -0
  111. devagent_spatial/live_lab_binding.py +128 -0
  112. devagent_spatial/model.py +297 -0
  113. devagent_spatial/native_reference.py +69 -0
  114. devagent_spatial/opc_snapshot.py +87 -0
  115. devagent_spatial/operations.py +284 -0
  116. devagent_spatial/operations_capture.py +47 -0
  117. devagent_spatial/operations_lab.py +64 -0
  118. devagent_spatial/original_r9.py +169 -0
  119. devagent_spatial/original_v8.py +81 -0
  120. devagent_spatial/preflight.py +140 -0
  121. devagent_spatial/preview_simulation.py +189 -0
  122. devagent_spatial/project_package.py +101 -0
  123. devagent_spatial/qualification.py +152 -0
  124. devagent_spatial/strict_json.py +29 -0
  125. devagent_spatial/studio.py +894 -0
  126. devagent_spatial/studio_workflow.py +277 -0
  127. devagent_spatial/test_reporting.py +87 -0
  128. devagent_spatial/v076_opc_fat.py +180 -0
  129. devagent_spatial/v078_commands.py +277 -0
  130. devagent_spatial/v078_native_commands.py +161 -0
  131. devagent_spatial/v07_fat.py +110 -0
  132. devagent_spatial/v07_native.py +318 -0
  133. devagent_spatial/v083_native.py +349 -0
@@ -0,0 +1,140 @@
1
+ """Pre-deployment OFFLINE integrity/cross-project qualification. No Gateway access.
2
+
3
+ This never claims Ignition runtime compatibility, live mapping, or FAT success.
4
+ """
5
+ from __future__ import annotations
6
+ import copy
7
+ import hashlib
8
+ import json
9
+ import tempfile
10
+ from pathlib import Path
11
+ from .generator import build, BuildHold, ROOT
12
+ from .qualification import audit
13
+ from .model import qualify
14
+ from .ignition_native import compile_candidate, verify_candidate, find_views, NativeHold
15
+ from .ignition_project import compile_project, verify_project, CONFIRM
16
+
17
+ EXAMPLES = ('single_floor', 'warehouse', 'water')
18
+ SCHEMA = 'devagent.spatial.offline_preflight.v0.4'
19
+
20
+
21
+ def _hash(path: Path) -> str:
22
+ return hashlib.sha256(path.read_bytes()).hexdigest()
23
+
24
+
25
+ def preflight(*, reference_zip=None, reference_sha256=None, template_view=None):
26
+ results = []
27
+ def result(name, passed, detail):
28
+ results.append({'gate':name,'status':'PASS' if passed else 'HOLD','detail':detail})
29
+
30
+ with tempfile.TemporaryDirectory(prefix='devagent-preflight-') as base:
31
+ tmp = Path(base)
32
+ checked_models = {}
33
+ for name in EXAMPLES:
34
+ try:
35
+ model_path = ROOT/'examples'/f'{name}.json'
36
+ original_sha = _hash(model_path)
37
+ model = json.loads(model_path.read_text(encoding='utf-8'))
38
+ report = qualify(model)
39
+ if report['status']!='PASS_STATIC_CONTRACT_ONLY':
40
+ raise ValueError('STATIC_CONTRACT_HOLD')
41
+ expected_floors = len({e.get('floor',1) for e in model['equipment']})
42
+ if name=='single_floor' and expected_floors != 1:
43
+ raise ValueError('SINGLE_FLOOR_NOT_SINGLE')
44
+ out = tmp/name
45
+ res = build(model,out)
46
+ audited = audit(out,purpose='preview')
47
+ if res['status']!='PASS_STATIC_BUILD_ONLY' or audited['status']!='PASS_CONCEPT_PREVIEW_ONLY':
48
+ raise ValueError('PREVIEW_AUDIT_HOLD')
49
+ if audited['equipment_count']!=len(model['equipment']) or audited['signals_proposed']!=len(model['signals']):
50
+ raise ValueError('MODEL_COUNTS_DIFFER')
51
+ if original_sha!=_hash(model_path):
52
+ raise ValueError('SOURCE_FILE_MUTATED')
53
+ checked_models[name]=model
54
+ result('PROJECT_'+name.upper(),True,{'assets':len(model['equipment']),
55
+ 'signals':len(model['signals']),'floors':expected_floors,
56
+ 'FAT':'NOT_EXECUTED','preview':'PASS_CONCEPT_ONLY'})
57
+ except (OSError,ValueError,BuildHold,KeyError,TypeError,RecursionError) as exc:
58
+ result('PROJECT_'+name.upper(),False,type(exc).__name__+': '+str(exc)[:100])
59
+
60
+ # Deliberately corrupt an already-built artifact and check the independent
61
+ # auditor fails closed. Re-hashing a changed file must not bypass semantic checks.
62
+ if (tmp/'single_floor'/'BUILD.json').exists():
63
+ try:
64
+ builddoc = tmp/'single_floor'/'BUILD.json'
65
+ payload = json.loads(builddoc.read_text())
66
+ payload['ignition_hmi'] = 'VERIFIED'
67
+ builddoc.write_text(json.dumps(payload,indent=2)+'\n')
68
+ checksum = tmp/'single_floor'/'SHA256SUMS.txt'
69
+ updated=[]
70
+ for line in checksum.read_text().splitlines():
71
+ digest,relative=line.split(' ',1)
72
+ if relative=='BUILD.json':digest=_hash(builddoc)
73
+ updated.append(digest+' '+relative)
74
+ checksum.write_text('\n'.join(updated)+'\n')
75
+ report=audit(tmp/'single_floor',purpose='preview')
76
+ result('REHASHED_FALSE_PRODUCTION_CLAIM_REJECTED',
77
+ report['status']=='HOLD' and 'UNSUPPORTED_RUNTIME_CERTIFICATION' in report['issues'],
78
+ report['issues'])
79
+ except (ValueError,OSError,KeyError) as exc:
80
+ result('REHASHED_FALSE_PRODUCTION_CLAIM_REJECTED',False,type(exc).__name__)
81
+ else:
82
+ result('REHASHED_FALSE_PRODUCTION_CLAIM_REJECTED',False,'NO_PREVIEW_BUILT')
83
+
84
+ for mutation in ('duplicate_asset','invalid_role','write_authority'):
85
+ try:
86
+ model=copy.deepcopy(checked_models['single_floor'])
87
+ if mutation=='duplicate_asset':model['equipment'].append(copy.deepcopy(model['equipment'][0]))
88
+ elif mutation=='invalid_role':model['signals'][0]['role']=[]
89
+ else:model['signals'][0]['write_authorized']=True
90
+ q=qualify(model)
91
+ result('REJECT_'+mutation.upper(),q['status']=='HOLD',
92
+ [x['code'] for x in q['issues'] if x['severity']=='HOLD'])
93
+ except (ValueError,KeyError,TypeError) as exc:
94
+ result('REJECT_'+mutation.upper(),False,'EXCEPTION_'+type(exc).__name__)
95
+ if reference_zip is not None:
96
+ try:
97
+ if not reference_sha256 or not template_view:raise NativeHold('SHA_AND_TEMPLATE_REQUIRED')
98
+ reference_zip=Path(reference_zip)
99
+ if _hash(reference_zip)!=reference_sha256:raise NativeHold('SOURCE_SHA256_MISMATCH')
100
+ index=find_views(reference_zip)
101
+ if template_view not in index['views']:raise NativeHold('REFERENCE_VIEW_UNAVAILABLE')
102
+ dst=tmp/'native_candidate.zip'
103
+ status=compile_candidate(checked_models['single_floor'],reference_zip,
104
+ template_view,reference_sha256,dst)
105
+ verification=verify_candidate(checked_models['single_floor'],reference_zip,
106
+ template_view,reference_sha256,dst)
107
+ # Require a standalone, conservative project candidate for
108
+ # THREE materially different customer systems. Generated only
109
+ # in the transient test directory; never contacts a Gateway.
110
+ project_results=[]
111
+ for scenario,customer_model in checked_models.items():
112
+ project_out=tmp/f'{scenario}-project.zip'
113
+ project_name='DevAgent_'+scenario.upper()+'_LAB'
114
+ pr=compile_project(customer_model,reference_zip,template_view,
115
+ reference_sha256,project_name,project_out,CONFIRM)
116
+ pv=verify_project(customer_model,reference_zip,template_view,
117
+ reference_sha256,project_name,project_out)
118
+ project_results.append({'scenario':scenario,'status':'PASS' if
119
+ pr['status']==pv['status']=='PASS_OFFLINE_ISOLATED_PROJECT_CANDIDATE_ONLY'
120
+ else 'HOLD','members':pv['members_verified']})
121
+ result('REFERENCE_NATIVE_OFFLINE',
122
+ status['status']==verification['status']=='PASS_OFFLINE_NATIVE_VIEW_CANDIDATE_ONLY'
123
+ and len(project_results)==len(EXAMPLES)
124
+ and all(x['status']=='PASS' for x in project_results),
125
+ {'source_sha256':reference_sha256,'native_import':'NOT_TESTED',
126
+ 'generated_project_candidates':project_results})
127
+ except (OSError,ValueError,KeyError,NativeHold,TypeError) as exc:
128
+ result('REFERENCE_NATIVE_OFFLINE',False,str(exc)[:160])
129
+ else:
130
+ result('REFERENCE_NATIVE_OFFLINE',False,'APPROVED_R9_NATIVE_EXPORT_REQUIRED')
131
+ return {
132
+ 'schema':SCHEMA,
133
+ 'status':'PASS_OFFLINE_PRETEST_ONLY' if all(r['status']=='PASS' for r in results if r['gate']!='REFERENCE_NATIVE_OFFLINE') else 'HOLD',
134
+ 'production_release':'HOLD',
135
+ 'ignition_project_import':'NOT_TESTED',
136
+ 'native_reference':next(x['status'] for x in results if x['gate']=='REFERENCE_NATIVE_OFFLINE'),
137
+ 'real_opc_ua':'NOT_TESTED','hmi_runtime':'NOT_TESTED','FAT':'NOT_EXECUTED',
138
+ 'R9_design_parity':'NOT_VERIFIED',
139
+ 'gates':results
140
+ }
@@ -0,0 +1,189 @@
1
+ """Project-scoped, in-memory SYNTHETIC preview demonstration.
2
+
3
+ This module NEVER talks to OPC UA, Ignition or PLCs, changes tags or asserts FAT
4
+ qualification. It models conceptual material flow using only declared graph edges.
5
+ Separate from real Studio R2's guarded tag readback workflow.
6
+ """
7
+ from __future__ import annotations
8
+ import datetime as dt
9
+ import threading
10
+ import time
11
+ from collections import deque
12
+ from .operations import evaluate, mapping_digest
13
+
14
+ MAX_SECONDS = 900
15
+ PREVIEW_PACES = {'slow':180.0, 'normal':120.0, 'fast':75.0}
16
+
17
+
18
+ class PreviewSimulationHold(ValueError): pass
19
+
20
+
21
+ def main_path(model):
22
+ """Longest declared acyclic handling path (deterministic branch selection)."""
23
+ equipment = {e['id']: e for e in model['equipment']}
24
+ handling = {k for k,e in equipment.items() if e['kind'] in ('conveyor','sorter','spiral','junction')}
25
+ if not handling:raise PreviewSimulationHold('NO_MATERIAL_HANDLING_ASSETS')
26
+ children = {k: [] for k in handling}
27
+ incoming = {k: 0 for k in handling}
28
+ for edge in model['connections']:
29
+ a,b = edge['from'],edge['to']
30
+ if a in handling and b in handling:
31
+ children[a].append(b);incoming[b]+=1
32
+ if not any(children.values()):raise PreviewSimulationHold('NO_DECLARED_MATERIAL_FLOW')
33
+ for paths in children.values():paths.sort()
34
+ # Never silently invent a path through missing or cyclic edges.
35
+ def visit(k, seen):
36
+ if k in seen:raise PreviewSimulationHold('CYCLIC_MATERIAL_FLOW_NEEDS_REVIEW')
37
+ options = [visit(c,seen|{k}) for c in children[k]]
38
+ return [k] + (max(options,key=lambda p:(len(p),tuple(p))) if options else [])
39
+ roots = sorted(k for k,n in incoming.items() if n == 0)
40
+ if not roots:raise PreviewSimulationHold('CYCLIC_MATERIAL_FLOW_OR_NO_ROOT')
41
+ return max((visit(r,set()) for r in roots),key=lambda p:(len(p),tuple(p)))
42
+
43
+
44
+ class PreviewSimulator:
45
+ def __init__(self):
46
+ self._lock = threading.RLock()
47
+ self._runs = {}
48
+
49
+ def _get(self,pid,model):
50
+ run=self._runs.get(pid)
51
+ if run and run['digest']!=mapping_digest(model):
52
+ self._runs.pop(pid,None)
53
+ raise PreviewSimulationHold('PROJECT_MAPPING_CHANGED_SIMULATION_STOPPED')
54
+ if run and (time.monotonic()-run['created'])>MAX_SECONDS:
55
+ self._runs.pop(pid,None)
56
+ raise PreviewSimulationHold('SIMULATION_EXPIRED_RESTART_REQUIRED')
57
+ return run
58
+
59
+ def active(self,pid):
60
+ with self._lock:return pid in self._runs
61
+
62
+ def action(self,pid,model,action,*,fault_asset=None,pace=None):
63
+ with self._lock:
64
+ if action=='start':
65
+ if pace not in (None,*PREVIEW_PACES):raise PreviewSimulationHold('UNKNOWN_PREVIEW_PACE')
66
+ path=main_path(model)
67
+ self._runs[pid]={'digest':mapping_digest(model),'path':path,'created':time.monotonic(),
68
+ 'tick':time.monotonic(),'elapsed':0.,'duration':PREVIEW_PACES[pace or 'normal'],'paused':False,'fault_asset':None,
69
+ 'history':deque(maxlen=40),'scanned':0,'generation':0,'acknowledged':False}
70
+ elif action=='stop':
71
+ self._runs.pop(pid,None)
72
+ return {'status':'SYNTHETIC_PREVIEW_STOPPED','production':'HOLD','opc_writes':False}
73
+ else:
74
+ run=self._get(pid,model)
75
+ if not run:raise PreviewSimulationHold('START_SIMULATION_FIRST')
76
+ self._advance(run)
77
+ if action=='pause':run['paused']=not run['paused']
78
+ elif action=='operator-stop':run['paused']=True
79
+ elif action=='operator-start':
80
+ if run['fault_asset'] is not None:raise PreviewSimulationHold('ACTIVE_FAULT_RESET_REQUIRED_BEFORE_START')
81
+ run['paused']=False
82
+ elif action=='operator-reset':
83
+ if not run['paused']:raise PreviewSimulationHold('STOP_BEFORE_SIMULATED_RESET')
84
+ run['fault_asset']=None;run['acknowledged']=False
85
+ run['elapsed']=0.0;run['history'].clear();run['tick']=time.monotonic()
86
+ elif action=='operator-ack':
87
+ if run['fault_asset'] is None:raise PreviewSimulationHold('NO_ACTIVE_SIMULATED_FAULT_TO_ACK')
88
+ if run['acknowledged']:raise PreviewSimulationHold('ALARM_ALREADY_ACKNOWLEDGED_IN_SIMULATION')
89
+ run['acknowledged']=True
90
+ elif action=='fault':
91
+ ids={s['asset_id'] for s in model['signals'] if s['role']=='fault' and s['datatype']=='Boolean'}
92
+ if fault_asset not in ids:raise PreviewSimulationHold('UNKNOWN_FAULT_ASSET')
93
+ run['fault_asset']=None if run['fault_asset']==fault_asset else fault_asset
94
+ run['acknowledged']=False
95
+ else:raise PreviewSimulationHold('UNKNOWN_SIMULATION_ACTION')
96
+ run=self._runs[pid]
97
+ return {'status':'SYNTHETIC_PREVIEW_ACTIVE','paused':run['paused'],
98
+ 'fault_asset':run['fault_asset'],'acknowledged':run['acknowledged'],'path':run['path'],'duration_seconds':run['duration'],'source':'IN_MEMORY_SYNTHETIC_NOT_OPC',
99
+ 'production':'HOLD','opc_writes':False,'ignition':'NOT_VERIFIED'}
100
+
101
+ def _advance(self,run):
102
+ now=time.monotonic(); delta=max(0.,min(now-run['tick'],10.))
103
+ if not run['paused'] and run['fault_asset'] is None:run['elapsed']+=delta
104
+ run['tick']=now
105
+
106
+ def status(self,pid,model):
107
+ with self._lock:
108
+ run=self._get(pid,model)
109
+ if not run:return {'status':'SIMULATION_OFF','source':'NONE','opc_writes':False}
110
+ return {'status':'SYNTHETIC_PREVIEW_ACTIVE','paused':run['paused'],
111
+ 'fault_asset':run['fault_asset'],'acknowledged':run['acknowledged'],'path':run['path'],'duration_seconds':run['duration'],
112
+ 'source':'IN_MEMORY_SYNTHETIC_NOT_OPC','production':'HOLD','opc_writes':False}
113
+
114
+ def reading(self,pid,model):
115
+ with self._lock:
116
+ run=self._get(pid,model)
117
+ if not run:return {'status':'SIMULATION_OFF'}
118
+ self._advance(run)
119
+ duration=run['duration'] # slow, explicitly labeled conceptual traversal (no PLC speed claim)
120
+ parcels=[]
121
+ for n in range(3):
122
+ seconds=run['elapsed']-n*(duration/4)
123
+ if seconds < 0:continue
124
+ # A completed parcel exits; a NEW parcel enters on the next
125
+ # cycle. Reusing the same identity across a 1->0 progress wrap
126
+ # would teleport a parcel from the chute back to the infeed.
127
+ cycle=int(seconds//duration)
128
+ phase=(seconds%duration)/duration
129
+ parcels.append({'id':f'LAB-{n+1:02d}-C{cycle:04d}',
130
+ 'progress':round(phase,6),'path':run['path']})
131
+ # Conceptual machine-specific virtual port sensors. These are NEVER
132
+ # customer OPC UA photoeye signals, even if the names resemble PE IN/OUT.
133
+ # Use a long enough conceptual gate for 1-second UI polls to observe it.
134
+ gates={asset:{'in':False,'out':False} for asset in run['path']}
135
+ length=len(run['path'])
136
+ for parcel in parcels:
137
+ scaled=parcel['progress']*length
138
+ index=min(length-1,int(scaled))
139
+ local=scaled-index
140
+ asset=run['path'][index]
141
+ gates[asset]['in'] |= local <= .23
142
+ gates[asset]['out'] |= local >= .77
143
+ virtual_pe=[{'asset_id':asset,'pe_in':v['in'],'pe_out':v['out']}
144
+ for asset,v in gates.items()]
145
+ # Project example photoeyes have no machine IN/OUT binding. Synthetic
146
+ # visualization MAY associate them by nearest declared same-floor route
147
+ # station; this is explicitly NOT a physical installation mapping.
148
+ equipment={e['id']:e for e in model['equipment']}
149
+ virtual_sensor_owners={}
150
+ for sensor in model['equipment']:
151
+ if sensor['kind']!='photoeye':continue
152
+ candidates=[( (sensor['position']['x']-equipment[k]['position']['x'])**2+
153
+ (sensor['position']['z']-equipment[k]['position']['z'])**2,
154
+ k) for k in run['path'] if equipment[k]['floor']==sensor['floor']]
155
+ if candidates:virtual_sensor_owners[sensor['id']]=min(candidates)[1]
156
+ at=dt.datetime.now(dt.timezone.utc)
157
+ # monotonic wall-clock for the operations engine, even on rapid requests
158
+ if run['history']:
159
+ prev=dt.datetime.fromisoformat(run['history'][-1]['at'])
160
+ if at<=prev:at=prev+dt.timedelta(microseconds=1)
161
+ timestamp=at.isoformat()
162
+ values=[]
163
+ signal_roles={s['id']:s for s in model['signals']}
164
+ for signal in model['signals']:
165
+ role=signal['role'];dtype=signal['datatype'];asset=signal['asset_id']
166
+ if dtype=='Boolean':
167
+ on=(role=='run_feedback' and not run['paused'] and run['fault_asset'] is None)
168
+ if role=='fault':on=(asset==run['fault_asset'])
169
+ if role=='photoeye_blocked':
170
+ owner=virtual_sensor_owners.get(asset)
171
+ on=bool(owner and (gates[owner]['in'] or gates[owner]['out']))
172
+ value=not on if signal.get('polarity','') in ('active_low','ACTIVE_LOW') and role in ('fault','photoeye_blocked') else on
173
+ elif dtype=='Int32':
174
+ value=int(run['elapsed']//duration) if role in ('scan_count','delivered_count') else 0
175
+ elif dtype=='Double':value=38.0 if not run['paused'] and run['fault_asset'] is None else 0.0
176
+ elif dtype=='String':value='GOOD_READ' if role=='scanner_result' else 'LAB-DEMO'
177
+ else:raise PreviewSimulationHold('UNSUPPORTED_SIGNAL_TYPE')
178
+ values.append({'signal_id':signal['id'],'namespace_uri':signal['source']['namespace_uri'],
179
+ 'node_id':signal['source']['node_id'],'datatype':dtype,'quality':'GOOD',
180
+ 'source_timestamp':timestamp,'value':value})
181
+ run['history'].append({'at':timestamp,'observations':values})
182
+ receipt=evaluate(model,list(run['history']),origin='SYNTHETIC_OFFLINE_FIXTURE')
183
+ return {'status':'PASS_SYNTHETIC_PREVIEW_ONLY','source':'IN_MEMORY_SYNTHETIC_NOT_OPC',
184
+ 'authority':'PROJECT_SCOPED_DEMO_ONLY','receipt':receipt,'parcels':parcels,
185
+ 'virtual_pe':virtual_pe,'virtual_pe_source':'CONCEPTUAL_SYNTHETIC_PORTS_NOT_MAPPED',
186
+ 'acknowledged':run['acknowledged'],
187
+ 'paused':run['paused'],'fault_asset':run['fault_asset'],
188
+ 'ignition':'NOT_VERIFIED','opc_verified':False,'production':'HOLD','opc_writes':False,
189
+ 'hmi_fat':'NOT_VERIFIED'}
@@ -0,0 +1,101 @@
1
+ """Read-only customer project ZIP intake: reviewable Creator fields, never executable artifacts.
2
+
3
+ No archive member is extracted or executed. Preview and FAT evidence files are ignored.
4
+ AI review, PLC mapping and production approval remain independent.
5
+ """
6
+ from __future__ import annotations
7
+ import hashlib
8
+ import io
9
+ import re
10
+ import zipfile
11
+ from pathlib import PurePosixPath
12
+
13
+ MAX_PACKAGE_BYTES = 8_000_000
14
+ MAX_MEMBERS = 180
15
+ MAX_EXPANDED_BYTES = 20_000_000
16
+ MAX_INPUT_BYTES = 120_000
17
+ MAX_PROJECTS = 4
18
+ FILES = {
19
+ 'equipment_csv': ('02_Equipment_IMPORT.csv','02_Equipment.csv','equipment.csv'),
20
+ 'connections_csv': ('03_Connections_IMPORT.csv','03_Connections.csv','connections.csv'),
21
+ 'signals_csv': ('04_Training_OPC_Signals_IMPORT.csv','04_Signals_DEMO_ONLY.csv','04_Training_OPC_Signals.csv','signals.csv'),
22
+ 'requirements': ('06_Requirements_PASTE.txt','05_Requirements_15_Lines.txt','requirements.txt'),
23
+ 'description': ('01_Project_Description_PASTE.txt','01_Project_Brief.txt','project.txt'),
24
+ }
25
+ class PackageHold(ValueError):
26
+ pass
27
+
28
+ def inspect_package(name: str, raw: bytes) -> dict:
29
+ if not isinstance(name,str) or not name.lower().endswith('.zip') or not isinstance(raw,bytes):
30
+ raise PackageHold('PROJECT_PACKAGE_ZIP_REQUIRED')
31
+ if not 20 <= len(raw) <= MAX_PACKAGE_BYTES:
32
+ raise PackageHold('PROJECT_PACKAGE_SIZE_HOLD')
33
+ try:
34
+ with zipfile.ZipFile(io.BytesIO(raw)) as z:
35
+ members=z.infolist()
36
+ if not members or len(members)>MAX_MEMBERS or sum(m.file_size for m in members)>MAX_EXPANDED_BYTES:
37
+ raise PackageHold('PROJECT_PACKAGE_EXPANSION_LIMIT')
38
+ by_dir={}
39
+ seen=set()
40
+ for item in members:
41
+ filename=item.filename
42
+ path=PurePosixPath(filename)
43
+ if item.flag_bits & 1: raise PackageHold('PROJECT_PACKAGE_ENCRYPTED')
44
+ if item.create_system==3 and ((item.external_attr>>16)&0o170000)==0o120000:
45
+ raise PackageHold('PROJECT_PACKAGE_SYMLINK_REJECTED')
46
+ if item.is_dir():continue
47
+ if (not filename or filename.startswith('/') or '\\' in filename or
48
+ '..' in path.parts or any(part.startswith('.') for part in path.parts) or
49
+ ':' in filename or item.file_size>MAX_EXPANDED_BYTES):
50
+ raise PackageHold('PROJECT_PACKAGE_UNSAFE_PATH')
51
+ if filename in seen:raise PackageHold('PROJECT_PACKAGE_DUPLICATE_PATH')
52
+ seen.add(filename)
53
+ # Scan archive metadata only. HMI previews, executables, reports and reference models are ignored.
54
+ if path.name not in {v for names in FILES.values() for v in names}:continue
55
+ directory=path.parent.as_posix()
56
+ by_dir.setdefault(directory,{})[path.name]=item
57
+ options=[]
58
+ from .studio import from_wizard
59
+ from .model import qualify
60
+ for directory,available in sorted(by_dir.items()):
61
+ data={}
62
+ for field,names in FILES.items():
63
+ chosen=next((n for n in names if n in available),None)
64
+ if chosen:
65
+ member=available[chosen]
66
+ if member.file_size>MAX_INPUT_BYTES:raise PackageHold('PROJECT_INPUT_FILE_TOO_LARGE')
67
+ try:data[field]=z.read(member).decode('utf-8-sig')
68
+ except (UnicodeError,RuntimeError):raise PackageHold('PROJECT_INPUT_ENCODING_INVALID') from None
69
+ if not data.get('equipment_csv'):continue
70
+ label=PurePosixPath(directory).name or 'Customer project'
71
+ # Project name is a UI convenience, not an inferred equipment or identity.
72
+ name_text=('Imported '+label.replace('_',' ').replace('-',' '))[:90]
73
+ wizard={'name':name_text,'industry':'Unspecified Industry',
74
+ 'layout':'spatial_overview','palette':'r9_cyan',
75
+ 'description':data.get('description',''),
76
+ 'equipment_csv':data['equipment_csv'],
77
+ 'connections_csv':data.get('connections_csv','from,to\n'),
78
+ 'signals_csv':data.get('signals_csv','id,asset_id,role,datatype,namespace_uri,node_id,unit,polarity\n'),
79
+ 'requirements':data.get('requirements','')}
80
+ try:
81
+ model=from_wizard(wizard)
82
+ state=qualify(model)
83
+ except (ValueError,TypeError,KeyError):
84
+ raise PackageHold('PROJECT_INPUT_SCHEMA_HOLD') from None
85
+ if state.get('status')!='PASS_STATIC_CONTRACT_ONLY':
86
+ raise PackageHold('PROJECT_INPUT_VALIDATION_HOLD')
87
+ options.append({'id':str(len(options)),'label':label,
88
+ 'equipment':len(model['equipment']),
89
+ 'links':len(model['connections']),
90
+ 'signals':len(model['signals']),
91
+ 'requirements':len(model['requirements']),
92
+ 'training':any('TRAINING_ONLY' in s or 'training:' in s.lower() for s in [wizard['signals_csv']]),
93
+ 'wizard':wizard,
94
+ 'stage':'UNVERIFIED_CUSTOMER_INPUTS_ONLY'})
95
+ if len(options)>MAX_PROJECTS:raise PackageHold('TOO_MANY_PROJECTS_IN_PACKAGE')
96
+ if not options:raise PackageHold('NO_SUPPORTED_CUSTOMER_INPUT_BUNDLE')
97
+ return {'status':'REVIEW_REQUIRED','package_sha256':hashlib.sha256(raw).hexdigest(),
98
+ 'options':options,'production':'HOLD','ai_review':'NOT_RUN',
99
+ 'note':'Only customer input tables were inspected; generated HMI and FAT evidence in the ZIP were ignored.'}
100
+ except (zipfile.BadZipFile,zipfile.LargeZipFile,EOFError,RuntimeError) as ex:
101
+ raise PackageHold('PROJECT_PACKAGE_INVALID_ZIP') from ex
@@ -0,0 +1,152 @@
1
+ """Independent deterministic build audit. Never promotes a concept to live HMI qualification."""
2
+ from __future__ import annotations
3
+ import hashlib
4
+ import json
5
+ import re
6
+ from pathlib import Path, PurePosixPath
7
+ from .generator import enrich, fat_inventory, ROOT
8
+ from .model import qualify, json_embed
9
+ from .engineering_expertise import review as expert_review, LIBRARY_VERSION
10
+ from .control_intents import control_plan
11
+
12
+ ALLOWED = {
13
+ 'BUILD.json','SHA256SUMS.txt',
14
+ 'contracts/project.json','contracts/compiled-scene.json','contracts/design-system.json','contracts/operations-policy.json',
15
+ 'reports/static-validation.json','reports/fat-inventory.json','reports/engineering-review.json','reports/control-readiness.json',
16
+ 'viewer/index.html','viewer/equipment_controls.mjs','viewer/facility2d_core.mjs','viewer/facility2d_app.mjs','viewer/facility2d_simulation.mjs','viewer/facility2d.css','viewer/style.css','viewer/viewer.js','viewer/three_scene.js','viewer/flow_geometry.mjs','viewer/r9_transport.mjs','viewer/fallback_scene.js','viewer/operations_ui.js','viewer/runtime_feed.js',
17
+ }
18
+ VENDOR={'viewer/vendor/three.module.js','viewer/vendor/three.core.js','viewer/vendor/LICENSE-three.txt'}
19
+
20
+
21
+ def _parse_json(path):
22
+ return json.loads(path.read_text(encoding='utf-8'))
23
+
24
+
25
+ def audit(project_dir:Path, *, purpose:str='preview') -> dict:
26
+ """Validate artifact identity, content and traceability; never invent live evidence."""
27
+ if purpose not in ('preview','production'):
28
+ raise ValueError('INVALID_QUALIFICATION_PURPOSE')
29
+ root=Path(project_dir).expanduser().resolve()
30
+ issues=[]
31
+ def hold(code):issues.append(code)
32
+ if not root.is_dir():
33
+ return {'status':'HOLD','issues':['PROJECT_DIRECTORY_NOT_FOUND'],'purpose':purpose}
34
+ # No symlinks, unexpected files or unreadable paths. Files only from fixed allowlist.
35
+ present=set()
36
+ for f in root.rglob('*'):
37
+ if f.is_symlink():hold('SYMLINK_IN_ARTIFACT');continue
38
+ if f.is_file():present.add(f.relative_to(root).as_posix())
39
+ elif not f.is_dir():hold('UNEXPECTED_FILE_KIND')
40
+ if not ALLOWED.issubset(present):hold('REQUIRED_ARTIFACT_MISSING')
41
+ if not present.issubset(ALLOWED|VENDOR):hold('UNEXPECTED_ARTIFACT_FILE')
42
+ if present&VENDOR and present&VENDOR!=VENDOR:hold('INCOMPLETE_THREE_VENDOR')
43
+ if issues:
44
+ return {'status':'HOLD','purpose':purpose,'issues':sorted(set(issues)),'files_checked':0}
45
+ checksum_lines=(root/'SHA256SUMS.txt').read_text(encoding='utf-8').splitlines()
46
+ expected={}
47
+ for line in checksum_lines:
48
+ m=re.fullmatch(r'([0-9a-f]{64}) (.+)',line)
49
+ if not m:hold('INVALID_SHA256_MANIFEST');continue
50
+ h,relative=m.groups()
51
+ p=PurePosixPath(relative)
52
+ if p.is_absolute() or '..' in p.parts or relative in expected or '\\' in relative:
53
+ hold('UNSAFE_OR_DUPLICATE_MANIFEST_PATH');continue
54
+ expected[relative]=h
55
+ if set(expected)!=present-{'SHA256SUMS.txt'}:hold('SHA256_MANIFEST_COVERAGE_FAILURE')
56
+ for name,h in expected.items():
57
+ p=root/name
58
+ if p.is_file() and hashlib.sha256(p.read_bytes()).hexdigest()!=h:
59
+ hold('ARTIFACT_HASH_MISMATCH')
60
+ if issues:
61
+ return {'status':'HOLD','purpose':purpose,'issues':sorted(set(issues)),'files_checked':len(expected)}
62
+ # All issued preview resources are compiler-owned, not customer-authored JS.
63
+ # Even a rehashed output must not silently replace the approved R9 renderer.
64
+ for name in ('viewer.js','three_scene.js','r9_transport.mjs','flow_geometry.mjs','fallback_scene.js','operations_ui.js','runtime_feed.js','style.css','facility2d_core.mjs','facility2d_app.mjs','facility2d_simulation.mjs','facility2d.css','equipment_controls.mjs'):
65
+ if (root/'viewer'/name).read_bytes()!=(ROOT/'assets'/name).read_bytes():
66
+ hold('RENDERER_SOURCE_NOT_EQUAL_TO_ISSUED_COMPILER')
67
+ if issues:
68
+ return {'status':'HOLD','purpose':purpose,'issues':sorted(set(issues)),'files_checked':len(expected)}
69
+ try:
70
+ source=_parse_json(root/'contracts/project.json')
71
+ compiled=_parse_json(root/'contracts/compiled-scene.json')
72
+ validation=_parse_json(root/'reports/static-validation.json')
73
+ inventory=_parse_json(root/'reports/fat-inventory.json')
74
+ build=_parse_json(root/'BUILD.json')
75
+ design=_parse_json(root/'contracts/design-system.json')
76
+ operations_policy=_parse_json(root/'contracts/operations-policy.json')
77
+ engineering_review=_parse_json(root/'reports/engineering-review.json')
78
+ commands=_parse_json(root/'reports/control-readiness.json')
79
+ except (UnicodeError,ValueError,OSError) as e:
80
+ return {'status':'HOLD','purpose':purpose,'issues':['INVALID_ARTIFACT_JSON'],'detail':type(e).__name__}
81
+ fresh=qualify(source)
82
+ if fresh['status']!='PASS_STATIC_CONTRACT_ONLY' or validation!=fresh:hold('STATIC_VALIDATION_NOT_REPRODUCIBLE')
83
+ if compiled!=enrich(source):hold('COMPILED_SCENE_NOT_REPRODUCIBLE')
84
+ if commands!=control_plan(compiled):hold('COMMAND_INTENTS_NOT_REPRODUCIBLE')
85
+ if commands.get('runtime_command_authority')!='NONE' or commands.get('plc_opcua_writes')!='DISABLED' or any(x.get('enabled') is not False for x in commands.get('devices',[])):hold('UNAUTHORIZED_DEVICE_COMMAND_CLAIM')
86
+ if engineering_review!=expert_review(source):hold('ENGINEERING_REVIEW_NOT_REPRODUCIBLE')
87
+ if engineering_review.get('production')!='HOLD' or engineering_review.get('native_ignition_bindings')!='NOT_VERIFIED':
88
+ hold('ENGINEERING_REVIEW_UNSAFE_CLAIM')
89
+ if engineering_review.get('role_asset_mismatches'):hold('ENGINEERING_ROLE_ASSET_KIND_MISMATCH')
90
+ if build.get('industrial_knowledge_library')!=LIBRARY_VERSION or build.get('engineering_review')!='STATIC_INSTRUMENTATION_REVIEW_ONLY':
91
+ hold('INDUSTRIAL_KNOWLEDGE_LIBRARY_PROVENANCE_HOLD')
92
+ from .operations import policy as compute_operations_policy
93
+ try:
94
+ if operations_policy != compute_operations_policy(source):hold('OPERATIONS_POLICY_NOT_REPRODUCIBLE')
95
+ except (ValueError,KeyError,TypeError):hold('OPERATIONS_POLICY_INVALID')
96
+ if inventory.get('cases')!=fat_inventory(compiled) or inventory.get('execution')!='NOT_RUN':
97
+ hold('FAT_INVENTORY_NOT_REPRODUCIBLE')
98
+ if any(c.get('result')!='HOLD_NOT_EXECUTED' for c in inventory.get('cases',[])):
99
+ hold('UNSUPPORTED_FAT_PASS_CLAIM')
100
+ if design!=json.loads((ROOT/'design-system.json').read_text(encoding='utf8')):
101
+ hold('INVALID_DESIGN_SYSTEM')
102
+ if design.get('id')!='devagent_spatial_v1':hold('DESIGN_IDENTITY_MISMATCH')
103
+ if build.get('studio_to_hmi_runtime')!='READ_ONLY_AFTER_EXACT_MANIFEST_AND_PROTOCOL_PROBE' or build.get('hmi_runtime_source')!='ISOLATED_STUDIO_LAB_ONLY_NOT_IGNITION':
104
+ hold('STUDIO_HMI_SOURCE_AUTHORITY_CONTRACT_CHANGED')
105
+ if build.get('operations_mapping_digest')!=operations_policy.get('mapping_digest') or build.get('operations_intelligence')!='SOURCE_QUALIFIED_LAB_REPLAY_ONLY':
106
+ hold('OPERATIONS_POLICY_BINDING_UNSAFE')
107
+ if build.get('status')!='PASS_STATIC_BUILD_ONLY' or build.get('lab_controls')!='DISABLED' or build.get('device_control_intents')!='PRESENT_LOCKED_UNTIL_VERIFIED':
108
+ hold('BUILD_CAPABILITY_CLAIM_UNSAFE')
109
+ if build.get('ignition_project_export')!='NOT_IMPLEMENTED' or build.get('live_opcua')!='NOT_VERIFIED' or build.get('ignition_hmi')!='NOT_VERIFIED':
110
+ hold('UNSUPPORTED_RUNTIME_CERTIFICATION')
111
+ embedded=(root/'viewer/index.html').read_text(encoding='utf-8')
112
+ match=re.search(r'<script\s+type="application/json"\s+id="project-data">(.*?)</script>',embedded,re.S)
113
+ try:
114
+ if match is None or json.loads(match.group(1))!=compiled:
115
+ hold('PREVIEW_MODEL_MISMATCH')
116
+ except ValueError:
117
+ hold('PREVIEW_MODEL_INVALID')
118
+ embedded_ops=re.search(r'<script\s+type="application/json"\s+id="operations-policy">(.*?)</script>', embedded, re.S)
119
+ try:
120
+ if embedded_ops is None or json.loads(embedded_ops.group(1))!=operations_policy:
121
+ hold('OPERATIONS_POLICY_PREVIEW_MISMATCH')
122
+ except ValueError:hold('OPERATIONS_POLICY_PREVIEW_INVALID')
123
+ embedded_controls=re.search(r'<script\s+type="application/json"\s+id="control-plan">(.*?)</script>',embedded,re.S)
124
+ try:
125
+ if embedded_controls is None or json.loads(embedded_controls.group(1))!=commands:hold('DEVICE_CONTROL_PLAN_PREVIEW_MISMATCH')
126
+ except ValueError:hold('DEVICE_CONTROL_PLAN_INVALID')
127
+ expected_html=(ROOT/'assets'/('facility2d_viewer.html' if source['layout']=='standard_2d_flow' else 'operator_viewer.html')).read_bytes().replace(
128
+ b'__EMBEDDED_PROJECT__', json_embed(compiled).encode('utf-8')).replace(
129
+ b'__EMBEDDED_OPERATIONS_POLICY__', json_embed(operations_policy).encode('utf-8')).replace(
130
+ b'__EMBEDDED_CONTROL_PLAN__', json_embed(commands).encode('utf-8'))
131
+ if (root/'viewer/index.html').read_bytes()!=expected_html:
132
+ hold('COMPILED_VIEW_HTML_NOT_EQUAL_TO_ISSUED_COMPILER')
133
+ if build.get('design_variant',source['layout'])!=source['layout']:hold('DESIGN_VARIANT_MISMATCH')
134
+ if (present&VENDOR==VENDOR)!=bool(build.get('three_vendor_bundled')):
135
+ hold('RENDERER_MANIFEST_MISMATCH')
136
+ if purpose=='production':
137
+ hold('NATIVE_IGNITION_COMPILER_NOT_IMPLEMENTED')
138
+ hold('LIVE_OPCUA_HMI_FAT_NOT_VERIFIED')
139
+ hold('CUSTOMER_ACCEPTANCE_NOT_VERIFIED')
140
+ return {
141
+ 'schema':'devagent.spatial.artifact_qualification.v2',
142
+ 'status':'HOLD' if issues else 'PASS_CONCEPT_PREVIEW_ONLY',
143
+ 'purpose':purpose,
144
+ 'issues':sorted(set(issues)),
145
+ 'files_checked':len(expected),
146
+ 'equipment_count':len(compiled.get('equipment',[])),
147
+ 'signals_proposed':len(compiled.get('signals',[])),
148
+ 'fat_unexecuted':len(inventory.get('cases',[])),
149
+ 'ignition_native':'NOT_VERIFIED',
150
+ 'physical_mapping':'NOT_VERIFIED',
151
+ 'runtime_controls':'DISABLED',
152
+ }
@@ -0,0 +1,29 @@
1
+ """Reject ambiguous/unserializable JSON before it enters engineering models."""
2
+ from __future__ import annotations
3
+ import json
4
+ import math
5
+
6
+ class ContractJSONError(ValueError):
7
+ pass
8
+
9
+
10
+ def loads(raw: str | bytes):
11
+ def pairs(items):
12
+ obj = {}
13
+ for k, v in items:
14
+ if k in obj:
15
+ raise ContractJSONError('DUPLICATE_JSON_KEY: '+k[:80])
16
+ obj[k] = v
17
+ return obj
18
+
19
+ def finite(value):
20
+ n = float(value)
21
+ if not math.isfinite(n):
22
+ raise ContractJSONError('NONFINITE_JSON_NUMBER')
23
+ return n
24
+
25
+ try:
26
+ return json.loads(raw, object_pairs_hook=pairs, parse_float=finite,
27
+ parse_constant=lambda val: (_ for _ in ()).throw(ContractJSONError('NONFINITE_JSON_CONSTANT')))
28
+ except (RecursionError, OverflowError) as exc:
29
+ raise ContractJSONError('JSON_COMPLEXITY_LIMIT') from exc