"""Reproduce the eight-branch AME drawing -> editor model audit. Run from the repository root with .venv/bin/python. The original inputs are read only. Mapping uses typed graph isomorphism and endpoint edit cost, never component display names; names are emitted only as evidence for review. """ from __future__ import annotations 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 HERE = ROOT / 'test/solver-newton-20260911/mql8' AME = ROOT / 'tests/data/test_mql.ame' INPUT = ROOT / 'tests/fixtures/legacy/test-mql-8.json' OUTPUT = ROOT / 'tests/data/test-mql-8-corrected.json' 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(): HERE.mkdir(parents=True, exist_ok=True) original = json.loads(INPUT.read_text()) 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) OUTPUT.write_text(json.dumps(corrected, ensure_ascii=False, indent=2) + '\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': {str(p.relative_to(ROOT)): {'sha256': hashlib.sha256(p.read_bytes()).hexdigest(), 'bytes': p.stat().st_size} for p in (AME, INPUT)}, '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') print(json.dumps({'counts': audit['counts'], 'matching': matching, 'parameters': parameter_changes, 'connections': connection_changes, 'simulation': audit['simulation']}, ensure_ascii=False, indent=2)) if __name__ == '__main__': main()