"""Reproduce the eight-branch AME drawing -> editor model audit. Run from the repository root with .venv/bin/python and --check to preserve the current project; omit --check to regenerate it. Audit evidence goes to the ignored test/model-audit/mql8 directory. Mapping uses typed graph isomorphism and endpoint edit cost, never component display names; names are emitted only as evidence for review. Time settings are reported separately; --require-same-time-settings also makes their equality a --check gate. Parameter/port mismatches always fail. """ from __future__ import annotations import argparse from collections import Counter, defaultdict from copy import deepcopy import hashlib import json import math import re from pathlib import Path import sys import tarfile ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT)) from tests.test_test_mql_ame_contract import ( _blocks, _element_text as element, _expected_parameters, _public_model_type, _resolve_globals, _value_fields, _SOURCE_FIELD_BY_PARAMETER, _numeric_project_value, ) from app.simulation.registry import get_component_model_spec from app.simulation.paths import CURRENT_MQL8_AME_PATH, CURRENT_MQL8_PROJECT_PATH HERE = ROOT / 'test/model-audit/mql8' AME = CURRENT_MQL8_AME_PATH INPUT = CURRENT_MQL8_PROJECT_PATH OUTPUT = CURRENT_MQL8_PROJECT_PATH def read_drawing(): with tarfile.open(AME) as archive: cir = archive.extractfile('test_mql_.cir').read().decode('latin1') sim = archive.extractfile('test_mql_.sim').read().decode('ascii') model_info = archive.extractfile('test_mql_.modelinfo').read().decode('ascii') globals_ = _resolve_globals(cir) components, lines = _blocks(cir, 'COMP'), _blocks(cir, 'LINE') entities, edges = {}, [] def add(key, body): kind = _public_model_type(element(body, 'SUB_NAME')) entities[key] = { 'modelType': kind, 'alias': element(body, 'ALIAS'), 'parameters': _expected_parameters(body, kind, globals_), 'fields': {k: vars(v) for k, v in _value_fields(body).items()}, 'geometry': element(body, 'COMP_GEOMETRY'), 'outputType': element(body, 'OUTPUT_TYPE'), 'scopeId': element(body, 'CIRCUIT_SCOPE_ID'), 'drawingPorts': [{'position': element(p, 'PORT_POS'), 'face': element(p, 'PORT_FACE')} for p in _blocks(body, 'COMP_PORT')], } def port(component, index): kind = entities[component]['modelType'] if index == 0 and kind in ('amesim_forc', 'amesim_pnvo001'): return 'res' if index == 0 and kind in ('amesim_step0', 'amesim_ud00'): return 'out' return f'port_{index + 1}' for index, body in enumerate(components): add(f'component:{index}', body) contacts = set() for index, body in enumerate(components): for pindex, pbody in enumerate(_blocks(body, 'COMP_PORT')): if element(pbody, 'PORT_CONNECT') != '1': continue for conn in _blocks(pbody, 'CONNECT'): other = int(element(conn, 'CONNECT_ENTITY_NUM')) op = int(element(conn, 'CONNECT_ENTITY_PORT')) contacts.add(((index, pindex), (other, op))) for left, right in sorted(contacts): assert (right, left) in contacts, 'Non-reciprocal component contact' if left > right: continue a, b = f'component:{left[0]}', f'component:{right[0]}' edges.append(((a, port(a, left[1])), (b, port(b, right[1])), 'contact')) for index, body in enumerate(lines): assert element(body, 'LINE_START_TYPE') == element(body, 'LINE_END_TYPE') == '0' a, b = ('component:' + element(body, 'LINE_START_ENTITY'), 'component:' + element(body, 'LINE_END_ENTITY')) left = a, port(a, int(element(body, 'LINE_START_PORT'))) right = b, port(b, int(element(body, 'LINE_END_PORT'))) if element(body, 'SUB_NAME') == 'DIRECT': edges.append((left, right, f'direct-line:{index}')) continue key = f'line:{index}' add(key, body) output_type = element(body, 'OUTPUT_TYPE') assert output_type in ('1', '2'), (key, output_type) # A reversed two-port line's first physical endpoint is submodel port 2. lp, rp = ('port_1', 'port_2') if output_type == '1' else ('port_2', 'port_1') edges.extend(((left, (key, lp), key), ((key, rp), right, key))) return entities, edges, globals_, sim, model_info def match_graph(entities, ame_edges, project): nodes = {n['id']: n for n in project['nodes']} types = {**{k: v['modelType'] for k, v in entities.items()}, **{k: v['data']['modelType'] for k, v in nodes.items()}} adjacency = {k: {} for k in types} for (a, ap), (b, bp), _ in ame_edges: assert b not in adjacency[a], 'Parallel entity edges need a multigraph matcher' adjacency[a][b], adjacency[b][a] = (ap, bp), (bp, ap) for edge in project['edges']: a, b = edge['source'], edge['target'] assert b not in adjacency[a], 'Parallel editor edges need a multigraph matcher' ap, bp = edge['sourceHandle'], edge['targetHandle'] adjacency[a][b], adjacency[b][a] = (ap, bp), (bp, ap) colors = types.copy() for _ in range(len(entities)): signatures = {k: (types[k], colors[k], tuple(sorted(colors[n] for n in adjacency[k]))) for k in types} vocabulary = {v: i for i, v in enumerate(sorted(set(signatures.values())))} updated = {k: vocabulary[v] for k, v in signatures.items()} if len(set(updated.values())) == len(set(colors.values())): colors = updated break colors = updated groups = defaultdict(lambda: [[], []]) for key in entities: groups[colors[key]][0].append(key) for key in nodes: groups[colors[key]][1].append(key) for ames, editors in groups.values(): assert len(ames) == len(editors), 'Typed topology differs' # All remaining structural symmetries in this exact input preserve # parameters, so selection within them does not hide parameter errors. assert len({json.dumps(entities[k]['parameters'], sort_keys=True) for k in ames}) == 1 candidates = {a: tuple(editors) for ames, editors in groups.values() for a in ames} best, best_cost, examined = None, math.inf, 0 mapping, used = {}, set() def choices(a): result = [] for b in candidates[a]: if b in used: continue cost = 0 for previous, assigned in mapping.items(): ae, be = adjacency[a].get(previous), adjacency[b].get(assigned) if bool(ae) != bool(be): break if ae: cost += sum(x != y for x, y in zip(ae, be)) else: result.append((cost, b)) return sorted(result) def visit(cost): nonlocal best, best_cost, examined if cost >= best_cost: return if len(mapping) == len(entities): examined += 1 best, best_cost = mapping.copy(), cost return remaining = [(a, choices(a)) for a in entities if a not in mapping] if any(not options for _, options in remaining): return # Each current candidate cost is charged only for edges to assigned # nodes, so this lower bound does not double count any edge. if cost + sum(options[0][0] for _, options in remaining) >= best_cost: return a, options = min(remaining, key=lambda item: (len(item[1]), -sum(n in mapping for n in adjacency[item[0]]), -len(adjacency[item[0]]), item[0])) for extra, b in options: mapping[a] = b used.add(b) visit(cost + extra) used.remove(b) del mapping[a] visit(0) assert best is not None, 'No typed topology isomorphism' return best, {'minimumEndpointEdits': best_cost, 'improvingSolutions': examined, 'typedRefinementGroups': len(groups), 'parameterPreservingSymmetries': True} def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--check', action='store_true', help='Audit current JSON against AME without changing the project; evidence goes to test/model-audit/mql8/.') parser.add_argument('--require-same-time-settings', action='store_true', help='With --check, also require identical start/stop/sample/max-step settings; otherwise report these separately from model semantics.') arguments = parser.parse_args() HERE.mkdir(parents=True, exist_ok=True) sources = {str(p.relative_to(ROOT)): {'sha256': hashlib.sha256(p.read_bytes()).hexdigest(), 'bytes': p.stat().st_size} for p in (AME, INPUT)} original = json.loads(INPUT.read_text(encoding='utf-8')) entities, edges, globals_, sim, model_info = read_drawing() mapping, matching = match_graph(entities, edges, original) corrected = deepcopy(original) corrected['name'] = 'test-mql-8-corrected' nodes = {n['id']: n for n in corrected['nodes']} parameter_audit, parameter_changes = [], [] for source, target in mapping.items(): entity, node = entities[source], nodes[target] spec = get_component_model_spec(entity['modelType']) assert node['data']['modelVersion'] == spec.model_version source_fields = _SOURCE_FIELD_BY_PARAMETER.get(entity['modelType'], {}) assert set(node['data']['parameters']) == set(entity['parameters']) for parameter in spec.parameters: name = parameter.name expected = entity['parameters'][name] actual = _numeric_project_value(node['data']['parameters'][name]) row = {'component': target, 'ameEntity': source, 'ameAlias': entity['alias'], 'parameter': name, 'unit': parameter.unit, 'old': actual, 'expected': expected, 'source': entity['fields'].get(source_fields.get(name, name)), 'matches': math.isclose(actual, expected, rel_tol=2e-14, abs_tol=1e-15)} parameter_audit.append(row) if not row['matches']: parameter_changes.append(row) node['data']['parameters'][name] = expected desired = {} for (a, ap), (b, bp), source in edges: ja, jb = mapping[a], mapping[b] key = frozenset((ja, jb)) assert key not in desired desired[key] = ({ja: ap, jb: bp}, source) connection_audit, connection_changes = [], [] cleared_contact_flags = [] for edge in corrected['edges']: ends, source = desired.pop(frozenset((edge['source'], edge['target']))) before = [(edge['source'], edge['sourceHandle']), (edge['target'], edge['targetHandle'])] after = [(edge['source'], ends[edge['source']]), (edge['target'], ends[edge['target']])] row = {'edgeId': edge['id'], 'ameSource': source, 'before': before, 'after': after, 'matches': before == after} connection_audit.append(row) if not row['matches']: connection_changes.append(row) edge['sourceHandle'], edge['targetHandle'] = after[0][1], after[1][1] # A moved endpoint no longer certifies the old geometric contact. # Keep its explicit connection visible; physical adjacency is intact. if edge.get('data', {}).get('isContactEdge'): edge['data']['isContactEdge'] = False cleared_contact_flags.append(edge['id']) assert not desired # The corrected shared P4 symbol uses p1 up, p2 left, p3 down, p4 right. # Read every drawing port to ensure the saved geometry needs exactly 180°. display_audit, p4_nodes = [], set() for source, target in mapping.items(): entity = entities[source] if entity['modelType'] != 'amesim_p4node2': continue expected = [('10 19', '1'), ('19 10', '3'), ('10 1', '0'), ('1 10', '2')] assert entity['geometry'] == '1' assert [(p['position'], p['face']) for p in entity['drawingPorts']] == expected data = nodes[target]['data'] display_audit.append({'component': target, 'ameEntity': source, 'ameAlias': entity['alias'], 'ameGeometry': entity['geometry'], 'drawingPorts': entity['drawingPorts'], 'previousRotation': data.get('rotation'), 'previousMirrored': data.get('mirrored'), 'rotation': 180, 'mirrored': False, 'portSides': {'port_1': 'bottom', 'port_2': 'right', 'port_3': 'top', 'port_4': 'left'}}) data['rotation'], data['mirrored'] = 180, False p4_nodes.add(target) cleared_route_points = [] for edge in corrected['edges']: if edge['source'] in p4_nodes or edge['target'] in p4_nodes: if 'routePoints' in edge.get('data', {}): del edge['data']['routePoints'] cleared_route_points.append(edge['id']) # The first five .sim values are recorded directly; no interpretation of # undocumented solver-enumeration slots is needed for this correction. sim_values = [float(x) for x in sim.splitlines()[0].split()] corrected['simulation'].update(t_start=sim_values[0], t_stop=sim_values[1], step=sim_values[2], max_step=sim_values[3]) from app.main import ReactFlowProjectPayload, build_reactflow_system_xml, compile_reactflow_network from app.simulation.native_codegen.compiler import compile_native_program # Browser export resolves arithmetic editor expressions on a separate copy. executable = deepcopy(corrected) for node in executable['nodes']: node['data']['parameters'] = {k: _numeric_project_value(v) for k, v in node['data']['parameters'].items()} payload = ReactFlowProjectPayload.model_validate(executable) network = compile_reactflow_network(payload) program = compile_native_program(network) if not arguments.check: OUTPUT.write_text(json.dumps(corrected, ensure_ascii=False, indent=2) + '\n', encoding='utf-8', newline='\n') xml = build_reactflow_system_xml(payload) (HERE / 'corrected.xml').write_bytes(xml if isinstance(xml, bytes) else xml.encode()) # The archive's parameter table and run input have matching row order. with tarfile.open(AME) as archive: param_lines = archive.extractfile('test_mql_.param').read().decode('latin1').splitlines() data_lines = archive.extractfile('test_mql_.data').read().decode('latin1').splitlines() assert len(param_lines) == len(data_lines) run_parameters = {re.search(r'Data_Path=(\S+)', text).group(1): (i+1, data_lines[i]) for i,text in enumerate(param_lines) if 'Data_Path=' in text} cache_checks, structural_fields = [], [] reverse_mapping = {v:k for k,v in mapping.items()} from tests.test_test_mql_ame_contract import _evaluate_expression for row in parameter_audit: if row['source'] is None: continue entity = entities[reverse_mapping[row['component']]] field = _SOURCE_FIELD_BY_PARAMETER.get(entity['modelType'], {}).get(row['parameter'], row['parameter']) path = field + '@' + row['ameAlias'] if path not in run_parameters: assert entity['modelType'] == 'amesim_lmechn1' and field == 'v1' structural_fields.append(path) continue line, expression = run_parameters[path] drawing_value = _evaluate_expression(row['source']['expression'], globals_) run_value = _evaluate_expression(expression, globals_) assert math.isclose(drawing_value, run_value, rel_tol=2e-14, abs_tol=1e-15), path cache_checks.append({'dataPath': path, 'parameterDataLine': line, 'drawingValue': drawing_value, 'runValue': run_value}) audit = { 'sources': sources, 'checkOnly': arguments.check, 'output': str(OUTPUT.relative_to(ROOT)), 'globals': globals_, 'matching': matching, 'counts': {'components': len(entities), 'connections': len(edges), 'parameters': len(parameter_audit), 'changedParameters': len(parameter_changes), 'changedConnections': len(connection_changes), 'states': len(program.state_keys), 'outputs': len(program.variables)}, 'simulation': {'old': original['simulation'], 'corrected': corrected['simulation'], 'ameRaw': sim, 'ameModelInfo': model_info, 'ameRtol': sim_values[4], 'note': 'BDF retained; AME solver enumeration is not equated with CVODE BDF. API rtol comes from the current backend default; this editor JSON does not carry rtol.'}, 'mapping': [{'ameEntity': a, 'ameAlias': entities[a]['alias'], 'component': b, 'type': entities[a]['modelType'], 'lineOutputType': entities[a]['outputType']} for a,b in sorted(mapping.items())], 'parameterChanges': parameter_changes, 'connectionChanges': connection_changes, 'clearedGeometricContactFlags': cleared_contact_flags, 'p4DisplayAlignment': display_audit, 'clearedP4RoutePoints': cleared_route_points, 'archiveParameterCrossCheck': {'tableRows': len(param_lines), 'checked': len(cache_checks), 'drawingOnlyStructuralFields': structural_fields, 'checks': cache_checks}, 'parameters': parameter_audit, 'connections': connection_audit, 'evaluationSchedule': program.evaluation_schedule, } (HERE / 'audit.json').write_text(json.dumps(audit, ensure_ascii=False, indent=2) + '\n', encoding='utf-8', newline='\n') print(json.dumps({'counts': audit['counts'], 'matching': matching, 'parameters': parameter_changes, 'connections': connection_changes, 'simulation': audit['simulation']}, ensure_ascii=False, indent=2)) if arguments.check: assert not parameter_changes, 'Current JSON parameters differ from AME; see audit.json.' assert not connection_changes, 'Current JSON connection ports differ from AME; see audit.json.' if arguments.require_same_time_settings: assert original['simulation'] == corrected['simulation'], 'Current JSON time settings differ from AME.' if __name__ == '__main__': main()