diff --git a/AGENTS.md b/AGENTS.md index 2aebea7..4ad90fd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,4 +43,5 @@ Documentation/Architecture before changing behavior. Scalar scanning and immutable plan-local regex sharing are selected private optimizations. Source-attached N3 snapshots and P1 tree/sparse-table projections live in JSONInspection. -P2 graph/store/set/matrix/retrieval/persistence and JSON text-sequence framing remain deferred. +NAV11 directed simple graph is a package-only JSONInspection experiment; no public graph API/product or automatic merge. +Store/set/matrix/retrieval/persistence and JSON text-sequence framing remain separate deferred trains. diff --git a/Documentation/Architecture/ExtractionManifest.json b/Documentation/Architecture/ExtractionManifest.json index 4a191c4..eec79bd 100644 --- a/Documentation/Architecture/ExtractionManifest.json +++ b/Documentation/Architecture/ExtractionManifest.json @@ -271,6 +271,18 @@ "attribution": "User-authored packet fixture input; original integration/tests Apache-2.0. Packet source/provenance retained separately.", "validation": "TypedConstructionTests.swift and public G3Acceptance consumer; strict native compiler/evaluator authority", "evidence": "Release1.6/G3 qualification and materialized per-case receipts" + }, + { + "repository": "ThorFuchs/Swift-JSON", + "commit": "1a0306d076a08c8c43281f91412a11cd744ff3f6", + "destination": "Sources/JSONInspection/Graph", + "disposition": "Original Apache-2.0 NAV11 experimental source-backed directed simple graph; no upstream graph code adapted. Canonical Navigation/context and JSON comparison remain authorities. Independent packet fixtures retained with SHA-bound oracle.", + "validation": [ + "Tests/JSONInspectionTests/GraphAcceptanceTests.swift", + "Scripts/qualify_graph_context.py", + "Scripts/qualify_graph.py" + ], + "evidence": "Scripts/ci.py source-bound native/Linux/safety/compiler receipts; private symbols only" } ], "original_implementation_notes": [ diff --git a/Documentation/Architecture/StructuralIntelligence/NAV11/APICandidate.md b/Documentation/Architecture/StructuralIntelligence/NAV11/APICandidate.md new file mode 100644 index 0000000..e4c881c --- /dev/null +++ b/Documentation/Architecture/StructuralIntelligence/NAV11/APICandidate.md @@ -0,0 +1,31 @@ +# Graph API proposal for later owner review + +All NAV11 types and operations are **package-only**, not application API. Product +count remains ten, dependencies remain empty, Navigation56/Inspection95 unchanged. +The proposed future public subset, contingent on separate review, is: + +- Immutable `JSONGraphProfile(nodesAt:edgesAt:nodeIDKey:edgeSourceKey:edgeTargetKey:)`. +- `JSONGraphProjection.build(document:profile:limits:maximumNodes:maximumEdges:)` + returning `JSONOperationReport`, defaults 10000/50000. +- Read-only captured document/profile/nodeCount/edgeCount and complete traversal + summary. IDs/CSR buffers are research metadata; promoting their raw arrays is + not proposed. Source-local node/edge ordinals have no cross-view identity. +- `nodeValue(at:limits:)`, `edgeValue(at:limits:)`, `nodeSpan(at:limits:)` and + `outgoingEdges(of:limits:maximumResults:)` with independent finite whole-source + admissions. Returned JSON aliases receive additional owned-value accounting. +- `breadthFirst(fromNode:limits:maximumFrontier:maximumVisited:maximumResults:)` + returning original node ordinals; defaults 10000 each, start included, complete + reachable component. Zero caps refuse a real start; an empty graph has no start. +- Typed semantic errors with original row/field paths; core missing/kind/resource + and cancellation errors stay intact. No partial-success variant exists. + +BFS stores queue history as returned output; frontier means pending entries, while +visited/result caps independently bound total enqueues/output. Graph metadata +aliases, visited flags and queue entries share the query's original traversal +ledger. Scalar-exact linear resolution and O(N²+NE+E²) construction are explicit +costs, not optimized lookup claims. No new JSON identity/path or serialization. + +Actual compiled spelling is in Sources/JSONInspection/Graph. Constructors remain +fileprivate. Compiler probes reject external type access, private initializers, +mutation, internal reducers, and context escape. No public DocC examples imply +these private declarations are available. No merge or promotion is authorized. diff --git a/Documentation/Architecture/StructuralIntelligence/NAV11/GraphADR.md b/Documentation/Architecture/StructuralIntelligence/NAV11/GraphADR.md new file mode 100644 index 0000000..16377f1 --- /dev/null +++ b/Documentation/Architecture/StructuralIntelligence/NAV11/GraphADR.md @@ -0,0 +1,57 @@ +# NAV11 directed simple graph experiment + +Package-only JSONInspection research, based on merged Navigation/Inspection; +no NAV10, new product, public symbol or graph JSON model. One original +`JSONTraversal.inspectOwned` invocation selects `[nodesAt, edgesAt]` with +`.rejectAmbiguous` and depth one. All strict root selections and overlap checks +precede callbacks; nodes are validated before edges, in their physical order. + +Negative capacities are configuration failures before admission. Existing core +precedence is retained: complete source admission precedes strict selection, +overlap and root-kind checks. No separate unmetered profile scan or second ledger +is added to move those checks ahead of admission. Missing roots preserve core +`missingMember`/`missingTarget`; wrong root kinds preserve typed core access errors. +Paths and explicit keys are immutable. Reusing a field spelling across roles is +intentional and valid (an edge's single field may explicitly name both ends of a +self-edge); no field inference or silent repair occurs. + +The exclusive context adds mutating delegates only: direct string member selection, +scalar-exact string comparison and matching alias release. JSONObject.lookup and +JSONComparison.textEquals remain authorities. Missing, null and wrong kind are +reported separately; strings own 32+UTF8 logical bytes until released or handed off. +Profile keys are explicitly key-admitted on that meter even for empty arrays; +the preexisting retainKey delegate gains an opt-in caller-input admission flag, +with its original default behavior/counters preserved. +No cursor, scope, ledger or noncopyable capability leaves the reducer. Original +2-positive/20-negative codegen tests are unchanged; helper escape probes and runtime +parity run separately in Debug and Release. A compiler crash blocks implementation. + +Graph metadata is source-local: first row ordinals, exact ID aliases, endpoint +ordinals and CSR offsets/edge ordinals in original edge order. Original source is +transferred once after complete validation/finalization. Defaults are 10,000 nodes, +50,000 edges; each independently configurable finite cap is inclusive and still +subject to all 49 core metrics. Linear exact scans may cost O(N²+NE+E²). + +Later value/span/catalogue/BFS calls start explicit independent full-source +operations. BFS has a head-index queue, visited-at-enqueue and source-edge order. +Defaults: 10,000 frontier entries, visited nodes and returned nodes. Queue history +remains charged until handoff; frontier capacity means live pending entries. +Results certify the complete reachable component including the start, not global +reachability. Failure/cancellation returns no partial result. + +Logical policy V1: 256 fixed builder bytes, 32+UTF8 per node ID alias, +16 per node record, 24 per edge record, 8 per offset and adjacency reference, +plus exact profile keys/paths and the original source. Queries admit graph metadata +aliases, queue/output slots (8 each), visited flags (1 each) and their source; +returned JSON aliases additionally receive core owned-retention accounting. +These are logical quotas, not allocator/RSS or caller-instruction guarantees. + +No public promotion, merge, release, timing or speed claim. Controlled performance +remains HOLD. Store, persistence, graph editing, weights, algorithms other than BFS, +query/CLI/HTML/network adapters and NAV10 batching remain separate work. + +BFS entry precedence is negative capacities, node bounds, whole source/metadata +admission, then enqueue's frontier/visited/result ceilings. Work/cancellation that +is observed first takes precedence. Edge rows validate source field then target +field before resolving source then target IDs, then compare ordered pairs. All +node rows precede all edge rows; a late duplicate node outranks a dangling edge. diff --git a/Documentation/Architecture/StructuralIntelligence/NAV11/SourceAuthority.md b/Documentation/Architecture/StructuralIntelligence/NAV11/SourceAuthority.md new file mode 100644 index 0000000..62504b6 --- /dev/null +++ b/Documentation/Architecture/StructuralIntelligence/NAV11/SourceAuthority.md @@ -0,0 +1,22 @@ +# NAV11 source authority + +Base main: 1a0306d076a08c8c43281f91412a11cd744ff3f6. NAV10 PR66/d566f28 is +independent and not a source dependency. Stable 1.6.1/e431f15 remains immutable. + +| Graph work | Existing delegate | Invocation boundary | +| --- | --- | --- | +| Complete root/source/map admission and failures | JSONReadScope, JSONAdmission, JSONWorkMeter | Original inspectOwned scope | +| Physical/virtual roots and exact overlap | JSONTraversal.execute, rejectAmbiguous | Same original scope, before callbacks | +| Row locations and bounded paths | JSONTraversalHit, JSONLocationComponent, context.appending | Same scope | +| Direct string member/presence/kind | JSONObject.lookup through mutating context delegate | No reader/new ledger | +| ID equality | JSONComparison.textEquals through context.equalStrings | Scalar-exact charged UTF8 comparison | +| Owned alias accounting, cancellation, terminal health | context.perform + scope.run/check | Original state, no escape | +| Adjacency and BFS | Package-only graph reducers | Checked work/retention in that invocation | +| Completed source handoff | context.transferDocumentOwnership | Once after complete valid construction | +| Later source value/span | JSONReadScope.select and sourceMap.span | Explicit new whole-source operation | +| Returned value alias | JSONAdmission.value(owned:true) | Same later-read ledger, additional output cost | + +No JSON, Pointer, value equality, schema, stream, Patch, HTML or CLI semantic change. +The new context methods are package-only and mutating; original ownership codegen +fixtures are byte-identical. ExtractionManifest records original Apache-2.0 code +and independent fixture provenance. Public symbol graphs must remain unchanged. diff --git a/Documentation/CI/README.md b/Documentation/CI/README.md index 2c1de90..2b8dc77 100644 --- a/Documentation/CI/README.md +++ b/Documentation/CI/README.md @@ -1,3 +1,16 @@ +# NAV11 experimental graph qualification + +The package graph/public API stays unchanged: nine libraries plus CLI; Navigation56 +and Inspection95. Graph symbols are package-only. Exact discovery is 108 Inspection +functions including G01–G32, with five Inspection sharing functions. All previous +identities, 49 core cap probes and 126 maintenance witnesses remain required. Native +and Linux add the graph context and package-boundary codegen/runtime qualifiers; +the original two-positive/twenty-negative ownership matrix is unchanged. Exact +identities and genuine primary diagnostics are checked, including crash rejection. + +The current source-bound receipts establish outcomes; the dated sections below +retain their original source scopes. Controlled performance remains HOLD. + # NAV8 Navigation V1 integration Current required discovery retains 779 native /763 Linux /675 WASI functions, diff --git a/Documentation/Qualification/GraphCases.json b/Documentation/Qualification/GraphCases.json new file mode 100644 index 0000000..4f9852a --- /dev/null +++ b/Documentation/Qualification/GraphCases.json @@ -0,0 +1,43 @@ +{ + "schema_version": 1, + "status": "experimental package-only", + "functions": [ + "JSONInspectionTests.graphG01EmptySourceAndNoValidStart()", + "JSONInspectionTests.graphG02IndependentOracleAndSourceOrder()", + "JSONInspectionTests.graphG03ScalarDistinctIDs()", + "JSONInspectionTests.graphG04SpecialIDsAreNotPaths()", + "JSONInspectionTests.graphG05DuplicateNodeLocations()", + "JSONInspectionTests.graphG06EqualPayloadsPreserveRows()", + "JSONInspectionTests.graphG07MissingNodeID()", + "JSONInspectionTests.graphG08NullNodeID()", + "JSONInspectionTests.graphG09WrongNodeIDKinds()", + "JSONInspectionTests.graphG10NonobjectNode()", + "JSONInspectionTests.graphG11MissingEdgeSource()", + "JSONInspectionTests.graphG12NullEdgeTarget()", + "JSONInspectionTests.graphG13WrongEndpointKind()", + "JSONInspectionTests.graphG14NonobjectEdge()", + "JSONInspectionTests.graphG15DanglingAndEmpty()", + "JSONInspectionTests.graphG16DuplicateOrderedEdge()", + "JSONInspectionTests.graphG17ReverseEdgesAreDistinct()", + "JSONInspectionTests.graphG18SelfEdgeIsVisitedOnce()", + "JSONInspectionTests.graphG19CycleTerminates()", + "JSONInspectionTests.graphG20IsolatesRemainSourceNodes()", + "JSONInspectionTests.graphG21SourceEdgeNeighborOrder()", + "JSONInspectionTests.graphG22DiamondEnqueuesOnce()", + "JSONInspectionTests.graphG23TypedPathsAndPointerDifferential()", + "JSONInspectionTests.graphG24OverlapAndDuplicateRootsFailBeforeRows()", + "JSONInspectionTests.graphG25WholeSourceAdmissionAndFreshQueries()", + "JSONInspectionTests.graphG26InclusiveWorkAndRetention()", + "JSONInspectionTests.graphG27EveryCapacityAndCheckedOverflow()", + "JSONInspectionTests.graphG28CancellationStopsBuildAndBFS()", + "JSONInspectionTests.graphG29FailurePrecedenceAndTerminalLatch()", + "JSONInspectionTests.graphG30ConcurrentViewsHaveIndependentHealth()", + "JSONInspectionTests.graphG31CapturedRootAndSourceMapCoherence()", + "JSONInspectionTests.graphG32CheckedMetadataAndExistingPublicSemantics()" + ], + "fixture_cases": 27, + "context_positive_codegen": 2, + "context_negative_codegen": 8, + "graph_positive_codegen": 2, + "graph_negative_codegen": 18 +} diff --git a/Documentation/Qualification/TestDiscovery.json b/Documentation/Qualification/TestDiscovery.json index 6f5fc8a..444f23b 100644 --- a/Documentation/Qualification/TestDiscovery.json +++ b/Documentation/Qualification/TestDiscovery.json @@ -12,7 +12,7 @@ "JSONCLITests": 88, "JSONNavigationTests": 47, "JSONSchemaExamplesTests": 98, - "JSONInspectionTests": 76 + "JSONInspectionTests": 108 }, "parameterized_invocations": 325, "composition_external_functions": 5, @@ -35,7 +35,7 @@ "JSONCLITests": 1, "JSONNavigationTests": 1, "JSONSchemaExamplesTests": 4, - "JSONInspectionTests": 4 + "JSONInspectionTests": 5 }, "coverage_unmapped": { "Sources/JSON/Pointer/JSONLocationComponent.swift": "declaration-only enum", @@ -76,7 +76,7 @@ "JSONCLITests": 88, "JSONNavigationTests": 47, "JSONSchemaExamplesTests": 98, - "JSONInspectionTests": 76 + "JSONInspectionTests": 108 }, "wasi_root_functions": { "JSONTests": 190, @@ -89,7 +89,7 @@ "JSONBridgesTests": 10, "JSONNavigationTests": 47, "JSONSchemaExamplesTests": 98, - "JSONInspectionTests": 76 + "JSONInspectionTests": 108 }, "wasi_parameterized_invocations": 288, "portable_shared_state_functions": { @@ -103,7 +103,7 @@ "JSONCLITests": 1, "JSONNavigationTests": 1, "JSONSchemaExamplesTests": 4, - "JSONInspectionTests": 4 + "JSONInspectionTests": 5 }, "export_functions": { "JSONSchemaTests": 1, diff --git a/Package.swift b/Package.swift index dcdd32c..e8928a2 100644 --- a/Package.swift +++ b/Package.swift @@ -22,7 +22,7 @@ let package = Package( // Opt-in N1 product candidate; promotion does not change core navigation semantics. .target(name: "JSONNavigation", dependencies: ["JSON"]), .target(name: "JSONInspection", dependencies: ["JSON", "JSONNavigation"]), - .testTarget(name: "JSONInspectionTests", dependencies: ["JSONInspection", "JSONNavigation", "JSON"], resources: [.copy("ProjectionFixtures")]), + .testTarget(name: "JSONInspectionTests", dependencies: ["JSONInspection", "JSONNavigation", "JSON"], resources: [.copy("ProjectionFixtures"), .copy("GraphFixtures")]), .testTarget(name: "JSONNavigationTests", dependencies: ["JSONNavigation", "JSON"], exclude: ["Fixtures"]), .target(name: "JSONBridges", dependencies: ["JSON"]), .testTarget(name: "JSONIntegrationTests", dependencies: ["JSON", "JSONSchema", "JSONStreaming", "JSONPatch"]), diff --git a/README.md b/README.md index 91ad1db..8e50b7d 100644 --- a/README.md +++ b/README.md @@ -716,3 +716,9 @@ location/value/span lookup is an explicitly separate full-source-admitted operat Ordinals are snapshot-local trusted positions, not durable identity. Logical retention is not RSS; G4 controlled performance remains HOLD, with no speed claim. These additions are absent from published 1.6.1 and do not publish 1.7.0. + +NAV11 experiments with a package-only, exact source-backed directed simple graph +inside JSONInspection. Graph construction and BFS are not public API and are not +part of stable 1.6.1. The [experimental contract](Documentation/Architecture/StructuralIntelligence/NAV11/GraphADR.md) +records finite capacities, source/error precedence and one-ledger ownership. There +is no graph product, performance claim or automatic promotion. diff --git a/Scripts/ci.py b/Scripts/ci.py index 096ea07..7274d5e 100644 --- a/Scripts/ci.py +++ b/Scripts/ci.py @@ -5,6 +5,7 @@ from archive_candidate import files from preflight import collect, collect_linux from testing_events import read_events, ROOT_PARAMETER_CASES +from graph_acceptance import check_graph_cases, check_graph_receipt from projection_acceptance import check_projection_cases, check_public_projection_receipt from test_discovery import DISCOVERY, ROOT_COUNTS, SHARED_COUNTS, PARAMETER_INVOCATIONS, check_restored @@ -16,7 +17,7 @@ SHARED_TESTS += '|navigationSharedImmutableRootIndependentInvocations' SHARED_TESTS += '|inspectionI17CoverageSpansReplacementAndSharedInvocations' SHARED_TESTS += '|snapshotS19NavigationParityAndIndependentInvocations|snapshotV22ConcurrentLookupsUseIndependentAdmissionAndHealth' -SHARED_TESTS += '|projectionP16SharedViewsHaveIndependentHealthAndLegacyStatistics' +SHARED_TESTS += '|projectionP16SharedViewsHaveIndependentHealthAndLegacyStatistics|graphG30ConcurrentViewsHaveIndependentHealth' R0_TESTS = 'readerProbe|indexProbes|duplicateRecognitionPrecedesValueAndClosingSyntax' def snapshot(): @@ -27,12 +28,14 @@ def commands(lane, output): output = output.relative_to(ROOT) if lane == 'inspection': result = [['python3', '-B', 'Scripts/public_check.py'], - ['swift', 'test', '--filter', 'inspection|snapshot|projectionP'], - ['swift', 'test', '-c', 'release', '--filter', 'inspection|snapshot|projectionP'], - ['swift', 'test', '-c', 'release', '--sanitize', 'address', '--scratch-path', '.build/inspection-address', '--filter', 'inspection|snapshot|projectionP'], + ['swift', 'test', '--filter', 'inspection|snapshot|projectionP|graphG'], + ['swift', 'test', '-c', 'release', '--filter', 'inspection|snapshot|projectionP|graphG'], + ['swift', 'test', '-c', 'release', '--sanitize', 'address', '--scratch-path', '.build/inspection-address', '--filter', 'inspection|snapshot|projectionP|graphG'], ['swift', 'build', '--target', 'JSONInspection', '--scratch-path', '.build/inspection-strict', '-Xswiftc', '-strict-memory-safety', '-Xswiftc', '-warnings-as-errors'], ['python3', '-B', 'Scripts/qualify_navigation_reducer.py', '--output', str(output / 'owned-codegen')], + ['python3', '-B', 'Scripts/qualify_graph_context.py', '--output', str(output / 'graph-context')], + ['python3', '-B', 'Scripts/qualify_graph.py', '--output', str(output / 'graph-compiler')], ['python3', '-B', 'Scripts/qualify_projections.py', '--output', str(output / 'projection-codegen')], ['python3', '-B', 'Scripts/qualify_public_projections.py', '--output', str(output / 'public-projection-consumer')]] return [c + ['--disable-xctest'] if sys.platform == 'linux' and c[:2] == ['swift', 'test'] else c for c in result] @@ -174,6 +177,8 @@ def commands(lane, output): ['python3', '-B', 'Scripts/qualify_projections.py', '--output', str(output / 'projection-codegen')], ['python3', '-B', 'Scripts/qualify_public_projections.py', '--output', str(output / 'public-projection-consumer')], ['python3', '-B', 'Scripts/qualify_navigation_reducer.py', '--output', str(output / 'owned-codegen')], + ['python3', '-B', 'Scripts/qualify_graph_context.py', '--output', str(output / 'graph-context')], + ['python3', '-B', 'Scripts/qualify_graph.py', '--output', str(output / 'graph-compiler')], ['swift', 'test', '--package-path', 'Tools/MobileQualification'], ['swift', 'run', '-c', 'release', '--package-path', 'Tools/MobileQualification', 'MobileQualification'], ['swift', 'test', '--package-path', 'Tools/MCPReadiness'], @@ -232,20 +237,20 @@ def check_result(command, code, log, events=None): expected = [DISCOVERY['root_functions']['JSONNavigationTests']] if events is not None: passed = passed and events['module_function_counts'] == {'JSONNavigationTests': expected[0]} and events['parameter_case_invocations'] == 0 - if any(f in command for f in ['inspection', 'inspection|snapshot|projectionP']): + if any(f in command for f in ['inspection', 'inspection|snapshot|projectionP|graphG']): expected = [DISCOVERY['root_functions']['JSONInspectionTests']] if events is not None: - passed = passed and events['module_function_counts'] == {'JSONInspectionTests': expected[0]} and events['parameter_case_invocations'] == 0 and check_projection_cases(events) + passed = passed and events['module_function_counts'] == {'JSONInspectionTests': expected[0]} and events['parameter_case_invocations'] == 0 and check_projection_cases(events) and check_graph_cases(events) passed = passed and sorted(counts) == expected if 'Tools/CompositionQualification' in command and events is not None: passed = passed and events['module_function_counts'] == {'CompositionScaleTests': DISCOVERY['composition_external_functions']} and events['parameter_case_invocations'] == 0 if 'qa' in command and events is not None: passed = passed and events['module_function_counts'] == DISCOVERY['qa_functions'] and check_restored(events) if 'thread' in command and 'JSONSchemaExamplesTests' not in command and events is not None: - passed = passed and events['module_function_counts'] == SHARED_COUNTS and check_projection_cases(events, shared=True) + passed = passed and events['module_function_counts'] == SHARED_COUNTS and check_projection_cases(events, shared=True) and check_graph_cases(events, shared=True) if '--filter' not in command and '--test-product' not in command and not any(x.startswith('Tools/') for x in command): if events is not None: - passed = passed and events['module_function_counts'] == ROOT_COUNTS and check_restored(events) and check_projection_cases(events) + passed = passed and events['module_function_counts'] == ROOT_COUNTS and check_restored(events) and check_projection_cases(events) and check_graph_cases(events) passed = passed and (events['parameterized_functions'] == ROOT_PARAMETER_CASES if events is not None else sum(int(n) for n in re.findall(r'with (\d+) test cases passed', log)) == PARAMETER_INVOCATIONS) if 'Tools/Qualification' in command: probes = re.findall(r'^CAP_PROBE (\w+) (\S+)$', log, re.MULTILINE) @@ -304,6 +309,9 @@ def check_result(command, code, log, events=None): passed = passed and 'TESTING_EVENT_RECEIPTS passed cases=19' in log.splitlines() if command[:3] == ['python3', '-B', 'Scripts/qualify_ergonomics.py']: passed = passed and 'ERGONOMICS_COMPILER passed commands=8 guide_fences=3' in log.splitlines() + if command[:3] in (['python3', '-B', 'Scripts/qualify_graph_context.py'], ['python3', '-B', 'Scripts/qualify_graph.py']): + data = json.loads((ROOT / command[command.index('--output') + 1] / 'receipt.json').read_text()) + passed = passed and check_graph_receipt(data, context='qualify_graph_context.py' in command[2]) if command[:3] == ['python3', '-B', 'Scripts/qualify_navigation_reducer.py']: passed = passed and 'REDUCER_CODEGEN passed positive=2 negative=20' in log.splitlines() if command[:3] == ['python3', '-B', 'Scripts/qualify_snapshot.py']: @@ -328,14 +336,17 @@ def check_result(command, code, log, events=None): if command[:3] == ['python3', '-B', 'Scripts/qualify_html_browser.py']: passed = passed and 'JSONHTML_BROWSER passed engines=3 cases=76 skips=0' in log.splitlines() if 'Scripts/test_qualification_harness.py' in command: - passed = passed and 'HARNESS_TESTS passed cases=17' in log.splitlines() + passed = passed and 'HARNESS_TESTS passed cases=18' in log.splitlines() return passed, counts def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--lane', choices=['composition', 'navigation', 'inspection', 'native', 'safety', 'docs', 'r0', 'qa', 'linux', 'linux-safety', 'wasm', 'browser', 'export', 'cli', 'schema-examples', 'schema-examples-safety', 'schema-examples-determinism', 'schema-examples-construction', 'schema-examples-advanced', 'schema-examples-qualification', 'coverage', 'linux-coverage', 'linux-qa', 'wasm-qa', 'archive'], required=True) parser.add_argument('--output') + parser.add_argument('--skip-host-measurements', action='store_true', help='For WASI qualification: leave optional host timings explicitly NOT RUN.') args = parser.parse_args() + if args.skip_host_measurements and args.lane not in ('wasm', 'wasm-qa'): + parser.error('--skip-host-measurements applies only to WASI lanes') output = ROOT / (args.output or '.build/ci/' + args.lane) output.mkdir(parents=True, exist_ok=True) inputs = snapshot() @@ -352,6 +363,8 @@ def save(): return 2 receipt['status'] = 'running' for index, command in enumerate(commands(args.lane, output)): + if args.skip_host_measurements and command[:3] == ['python3', '-B', 'Scripts/wasm_support.py']: + command += ['--skip-host-measurements'] plugin_path = os.environ.get('SWIFT_JSON_TESTING_PLUGIN_PATH') if command[:2] == ['swift', 'test'] and plugin_path: command += ['-Xswiftc', '-plugin-path', '-Xswiftc', plugin_path] diff --git a/Scripts/graph_acceptance.py b/Scripts/graph_acceptance.py new file mode 100644 index 0000000..57c6f9a --- /dev/null +++ b/Scripts/graph_acceptance.py @@ -0,0 +1,21 @@ +# SPDX-License-Identifier: Apache-2.0 +"""Exact graph identities and genuine compiler outcomes; no broad exit-zero gate.""" +import json,pathlib +ROOT=pathlib.Path(__file__).resolve().parents[1] +POLICY=json.loads((ROOT/'Documentation/Qualification/GraphCases.json').read_text()) +EXPECTED=frozenset(POLICY['functions']) +SHARED=frozenset({'JSONInspectionTests.graphG30ConcurrentViewsHaveIndependentHealth()'}) +def check_graph_cases(events,shared=False): + names=[n for n in events['function_names'] if n.startswith('JSONInspectionTests.graphG')] + return len(names)==len(set(names)) and set(names)==(SHARED if shared else EXPECTED) +def check_graph_receipt(data,context=False): + commands=data.get('commands',[]);expected_count=18 if context else 28;negative_count=8 if context else 18 + if data.get('status')!='passed' or len(commands)!=expected_count or len({c.get('name') for c in commands})!=expected_count:return False + negatives=[c for c in commands if c.get('expected_diagnostic')] + if len(negatives)!=negative_count:return False + for c in commands: + if c.get('status')!='passed' or c.get('compiler_crash') is not False:return False + if c.get('expected_diagnostic'): + if c.get('exit_code',0)<=0 or not any(c['expected_diagnostic'].lower() in d.lower() for d in c.get('primary_diagnostics',[])):return False + elif c.get('exit_code')!=0:return False + return True diff --git a/Scripts/qualify_graph.py b/Scripts/qualify_graph.py new file mode 100644 index 0000000..6e64e12 --- /dev/null +++ b/Scripts/qualify_graph.py @@ -0,0 +1,89 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Full-codegen proof of experimental graph invariants and package-only boundaries.""" +import argparse, hashlib, json, pathlib, re, subprocess +from archive_candidate import files +ROOT = pathlib.Path(__file__).resolve().parents[1] +POSITIVE = r'''import JSON +import JSONNavigation +import JSONInspection +@main struct GraphConsumer { + static func check(_ value: Bool) { precondition(value) } + static func main() throws { + let profile = JSONGraphProfile(nodesAt: [.key("nodes")], edgesAt: [.key("edges")], nodeIDKey: "id", edgeSourceKey: "from", edgeTargetKey: "to") + let document = JSONDocument(root: try JSON.parse(#"{"nodes":[{"id":"é","n":9007199254740993},{"id":"e\u0301"}],"edges":[{"from":"é","to":"e\u0301"}]}"#)) + let built = try JSONGraphProjection.build(document: document, profile: profile) + check(built.result.nodeCount == 2 && built.result.edgeCount == 1) + print("PRIVATE_GRAPH exact passed") + check(try built.result.breadthFirst(fromNode: 0).result == [0,1]) + print("PRIVATE_GRAPH bfs passed") + check(try built.result.outgoingEdges(of: 0).result == [0]) + print("PRIVATE_GRAPH adjacency passed") + check(try built.result.nodeValue(at: 0).result.objectValue?["n"]?.numberValue?.integer(as: UInt64.self) == 9007199254740993) + print("PRIVATE_GRAPH values passed") + let baseline = try JSONTraversal(document: document).visit { _,_ in .continue } + check(built.statistics[.nodes] == baseline.statistics[.nodes] && built.statistics[.retainedBytes] > 0) + print("PRIVATE_GRAPH admission passed") + } +} +''' +PROBES = [ + ('positive', POSITIVE, None, False), + ('external-graph', 'import JSONInspection\nlet x = JSONGraphProjection.self\n', 'cannot find', True), + ('external-profile', 'import JSONInspection\nlet x = JSONGraphProfile.self\n', 'cannot find', True), + ('external-failure', 'import JSONInspection\nlet x = JSONGraphFailure.self\n', 'cannot find', True), + ('external-record', 'import JSONInspection\nlet x = JSONGraphEdgeRecord.self\n', 'cannot find', True), + ('external-context', 'import JSONNavigation\nlet x = JSONTraversalContext.self\n', 'cannot find', True), + ('core-isolation', 'import JSON\nlet x = JSONGraphProjection.self\n', 'cannot find', True), + ('immutable-ids', 'import JSONInspection\nfunc f(_ x: inout JSONGraphProjection) { x.nodeIDs.removeAll() }\n', "'nodeIDs' is a 'let' constant", False), + ('forged-graph', 'import JSON\nimport JSONNavigation\nimport JSONInspection\nfunc f(_ d: JSONDocument, _ p: JSONGraphProfile, _ s: JSONTraversalSummary) { _ = JSONGraphProjection(document: d, profile: p, nodeIDs: [], edges: [], offsets: [], adjacency: [], traversal: s) }\n', 'initializer is inaccessible', False), + ('private-reducer', 'import JSONInspection\nlet x = GraphBuilder.self\n', 'cannot find', False), +] +CHECKS = {'exact', 'bfs', 'adjacency', 'values', 'admission'} + +def main(): + parser = argparse.ArgumentParser(description=__doc__); parser.add_argument('--output', required=True) + out = ROOT / parser.parse_args().output; out.mkdir(parents=True, exist_ok=True) + receipt = dict(status='running', scope='same-package graph proof; all graph declarations remain non-public', + source_inputs=[dict(path=p.relative_to(ROOT).as_posix(), sha256=hashlib.sha256(p.read_bytes()).hexdigest()) for p in files()], commands=[]) + def save(): (out / 'receipt.json').write_text(json.dumps(receipt, indent=2) + '\n') + def run(name, command, expected=None): + result = subprocess.run(command, cwd=ROOT, capture_output=True, text=True, timeout=900) + text = result.stdout + result.stderr; diagnostics = re.findall(r': error: ([^\n]+)', result.stderr) + crash = result.returncode < 0 or any(s in text for s in ['Stack dump:', 'Assertion failed', 'fatal error encountered during compilation', 'Found ownership error']) + passed = not crash and (result.returncode == 0 if expected is None else result.returncode > 0 and any(expected.lower() in d.lower() for d in diagnostics)) + log = out / (name + '.log'); log.write_text(text.replace(str(ROOT), '.')) + receipt['commands'].append(dict(name=name, command=[x.replace(str(ROOT), '.') for x in command], exit_code=result.returncode, + status='passed' if passed else 'failed', compiler_crash=crash, expected_diagnostic=expected, + primary_diagnostics=diagnostics, log=log.relative_to(ROOT).as_posix(), log_sha256=hashlib.sha256(log.read_bytes()).hexdigest())) + save() + if not passed: raise ValueError('Unexpected graph compiler outcome: ' + name) + return result.stdout + try: + identity = re.sub(r'[^a-zA-Z0-9_]', '_', ROOT.name.lower()) + if not identity or identity[0].isdigit(): identity = '_' + identity + for config in ['debug', 'release']: + run(config + '-build', ['swift','build','-c',config,'--target','JSONInspection']) + binpath = pathlib.Path(run(config + '-binpath', ['swift','build','-c',config,'--show-bin-path']).strip()) + modules = binpath / 'Modules' if (binpath / 'Modules').is_dir() else binpath + common = ['swiftc','-swift-version','6','-warnings-as-errors','-I',str(modules),'-Onone' if config == 'debug' else '-O','-parse-as-library'] + for name, text, diagnostic, external in PROBES: + source = out / (name + '.swift'); source.write_text(text) + run(config + '-' + name, common + ['-package-name','graph_external' if external else identity, + '-module-name','GraphCompilerProbe','-c',str(source),'-o',str(out / (config + '-' + name + '.o'))], diagnostic) + objects = [] + for module in ['JSON','JSONNavigation','JSONInspection']: + aggregate = binpath / (module + '.o') + selected = [aggregate] if aggregate.is_file() else sorted((binpath / (module + '.build')).glob('*.o')) + if not selected: raise ValueError('Missing objects: ' + module) + objects.extend(str(p) for p in selected) + binary = out / ('private-' + config) + run(config + '-link', common + ['-package-name',identity,str(out/'positive.swift'),*objects,'-o',str(binary)]) + output = run(config + '-run', [str(binary)]) + checks = re.findall(r'^PRIVATE_GRAPH (\S+) passed$', output, re.M) + if len(checks) != 5 or set(checks) != CHECKS: raise ValueError('Private projection consumer identity/count drift') + receipt['status'] = 'passed' + except (ValueError, OSError, subprocess.TimeoutExpired) as error: receipt.update(status='failed', error=str(error)) + save(); print('GRAPH_COMPILER ' + receipt['status'] + ' commands=28 positive_codegen=2 negative_codegen=18 private_cases=5/config') + return 0 if receipt['status'] == 'passed' else 1 +if __name__ == '__main__': raise SystemExit(main()) diff --git a/Scripts/qualify_graph_context.py b/Scripts/qualify_graph_context.py new file mode 100644 index 0000000..13619df --- /dev/null +++ b/Scripts/qualify_graph_context.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 +"""Code-generation and original-meter proof for the graph's exclusive delegates.""" +import argparse, hashlib, json, pathlib, re, subprocess +from archive_candidate import files +ROOT=pathlib.Path(__file__).resolve().parents[1] +POSITIVE='''import JSON +import JSONNavigation +struct Probe: JSONTraversalOwnedReducer { + var strings: [String] = [] + mutating func observe(_ hit: JSONTraversalHit, value: JSON, context: inout JSONTraversalContext) throws -> JSONTraversalAction { + if hit.relativeDepth == 0 { try context.retainKey("id", admittingCallerInput: true); try context.release(2); _ = try context.arrayCount(value, at: hit.absoluteLocation); return .continue } + let field = try context.directStringMember(value, key: "id", at: hit.absoluteLocation) + precondition(field.kind == .string) + if let text = field.text { let equal = try context.equalStrings(text, text); precondition(equal); strings.append(text) } + return .skipDescendants + } + mutating func finish(_ summary: JSONTraversalSummary, context: inout JSONTraversalContext) throws -> Int { + for text in strings { try context.releaseStringAlias(text) } + try context.work(7); return strings.count + } +} +@main struct Main { + static func main() throws { + let document = JSONDocument(root: try JSON.parse(#"{"n":[{"id":"é"}],"e":[{"id":"e\\u0301"}]}"#)) + let walk = JSONTraversal(document: document) + let roots: JSONTraversalScope = .virtual(roots: [[.key("n")],[.key("e")]], overlap: .rejectAmbiguous) + let base = try walk.visit(scope: roots, options: .init(depthLimit: 1)) { _,_ in .continue } + var reducer = Probe() + let owned = try walk.inspectOwned(scope: roots, options: .init(depthLimit: 1), reducer: &reducer) + precondition(owned.result == 2) + precondition(owned.statistics[.nodes] == base.statistics[.nodes]) + precondition(owned.statistics[.workUnits] > base.statistics[.workUnits]) + precondition(owned.statistics[.retainedBytes] == base.statistics[.retainedBytes]) + precondition(reducer.strings.count == 2) + var exact = Probe(); var limits = JSONLimits.balanced + limits[.workUnits] = owned.statistics[.workUnits] + _ = try JSONTraversal(document: document, limits: limits).inspectOwned(scope: roots, options: .init(depthLimit: 1), reducer: &exact) + limits[.workUnits] -= 1 + do { var short = Probe(); _ = try JSONTraversal(document: document, limits: limits).inspectOwned(scope: roots, options: .init(depthLimit: 1), reducer: &short); preconditionFailure() } + catch let error as JSONFailure { precondition(error.category == .resource) } + print("GRAPH_CONTEXT parity passed nodes=\\(owned.statistics[.nodes]) work=\\(owned.statistics[.workUnits]) retained=\\(owned.statistics[.retainedBytes])") + } +} +''' +PROBES={ + 'positive':(POSITIVE,None), + 'bound':('func f(_ c: inout JSONTraversalContext) { _ = c.directStringMember }', "cannot reference 'mutating' method as function value"), + 'escape':('func f(_ c: inout JSONTraversalContext) -> () throws -> Bool { { try c.equalStrings("a", "a") } }', "escaping closure captures 'inout' parameter"), + 'task':('func f(_ c: inout JSONTraversalContext) { _ = Task { try c.releaseStringAlias("a") } }', "escaping closure captures 'inout' parameter"), + 'store':('final class Box { var c: JSONTraversalContext? }; func f(_ c: inout JSONTraversalContext) -> Box { let b = Box(); b.c = consume c; return b }', 'missing reinitialization of inout parameter'), +} +def main(): + ap=argparse.ArgumentParser();ap.add_argument('--output',required=True);out=ROOT/ap.parse_args().output;out.mkdir(parents=True,exist_ok=True) + receipt={'status':'running','source_inputs':[{'path':p.relative_to(ROOT).as_posix(),'sha256':hashlib.sha256(p.read_bytes()).hexdigest()} for p in files()],'commands':[]} + def save(): (out/'receipt.json').write_text(json.dumps(receipt,indent=2)+'\n') + def run(name,args,expected=None): + p=subprocess.run(args,cwd=ROOT,capture_output=True,text=True,timeout=900);data=p.stdout+p.stderr + diagnostics=re.findall(r': error: ([^\n]+)',data);crash=p.returncode<0 or any(x in data for x in ['Stack dump:','Assertion failed','Found ownership error','fatal error encountered during compilation']) + passed=not crash and (p.returncode==0 if expected is None else p.returncode>0 and any(expected.lower() in d.lower() for d in diagnostics)) + log=out/(name+'.log');log.write_text(data) + receipt['commands'].append({'name':name,'command':args,'exit_code':p.returncode,'status':'passed' if passed else 'failed','expected_diagnostic':expected,'primary_diagnostics':diagnostics,'compiler_crash':crash,'log':log.relative_to(ROOT).as_posix(),'log_sha256':hashlib.sha256(log.read_bytes()).hexdigest()});save() + if not passed: raise ValueError(name) + return p.stdout + try: + identity=re.sub('[^a-zA-Z0-9_]','_',ROOT.name.lower()) + if identity[0].isdigit():identity='_'+identity + for config in ['debug','release']: + run(config+'-build',['swift','build','-c',config,'--target','JSONNavigation']) + bp=pathlib.Path(run(config+'-binpath',['swift','build','-c',config,'--show-bin-path']).strip());mods=bp/'Modules' if (bp/'Modules').is_dir() else bp + common=['swiftc','-swift-version','6','-warnings-as-errors','-package-name',identity,'-I',str(mods),'-O' if config=='release' else '-Onone','-parse-as-library'] + for name,(body,expected) in PROBES.items(): + f=out/(name+'.swift');f.write_text(body if name=='positive' else 'import JSON\nimport JSONNavigation\n'+body+'\n') + run(config+'-'+name,common+['-c',str(f),'-o',str(out/(config+'-'+name+'.o'))],expected) + objects=[] + for module in ['JSON','JSONNavigation']: + aggregate=bp/(module+'.o');objects+= [str(aggregate)] if aggregate.exists() else [str(p) for p in sorted((bp/(module+'.build')).glob('*.o'))] + exe=out/('parity-'+config);run(config+'-link',common+[str(out/'positive.swift'),*objects,'-o',str(exe)]) + output=run(config+'-run',[str(exe)]) + if len(re.findall(r'^GRAPH_CONTEXT parity passed nodes=\d+ work=\d+ retained=\d+$',output,re.M))!=1:raise ValueError('Missing parity witness') + receipt['status']='passed' + except (ValueError,OSError,subprocess.TimeoutExpired) as e:receipt.update(status='failed',error=str(e)) + save();print('GRAPH_CONTEXT '+receipt['status']+' positive=2 negative=8 runtime=2');return 0 if receipt['status']=='passed' else 1 +if __name__=='__main__':raise SystemExit(main()) diff --git a/Scripts/test_qualification_harness.py b/Scripts/test_qualification_harness.py index 843ec53..73360f0 100644 --- a/Scripts/test_qualification_harness.py +++ b/Scripts/test_qualification_harness.py @@ -12,6 +12,7 @@ from coverage import executable_objects, shared_objects, validate_export, without_schema_objects from test_discovery import DISCOVERY, ROOT_COUNTS, PARAMETER_INVOCATIONS, check_root, check_restored, RESTORED_FUNCTIONS from ci import check_result, commands +from graph_acceptance import EXPECTED as GRAPH_FUNCTIONS, check_graph_cases, check_graph_receipt from projection_acceptance import EXPECTED as PROJECTION_FUNCTIONS, PUBLIC_CHECKS, check_projection_cases, check_public_projection_receipt class HarnessTests(unittest.TestCase): @@ -61,11 +62,11 @@ def test_composition_discovery_and_capacity_limits_are_not_blanket_successes(sel # Combined-source checks must retain both independently owned optional products. for module, count, command in [ ('JSONNavigationTests', 47, ['swift', 'test', '--filter', 'navigation']), - ('JSONInspectionTests', 76, ['swift', 'test', '--filter', 'inspection|snapshot|projectionP']), + ('JSONInspectionTests', 108, ['swift', 'test', '--filter', 'inspection|snapshot|projectionP|graphG']), ('JSONSchemaExamplesTests', 98, ['swift', 'test', '--test-product', 'JSONSchemaExamplesTests']), ]: events = dict(bundle_counts=[count], module_function_counts={module: count}, - parameter_case_invocations=0, function_names=sorted(PROJECTION_FUNCTIONS) if module == 'JSONInspectionTests' else []) + parameter_case_invocations=0, function_names=sorted(PROJECTION_FUNCTIONS | GRAPH_FUNCTIONS) if module == 'JSONInspectionTests' else []) self.assertTrue(check_result(command, 0, '', events)[0]) for broken in [dict(events, bundle_counts=[count-1]), dict(events, module_function_counts={'WrongModule': count}), @@ -75,12 +76,12 @@ def test_composition_discovery_and_capacity_limits_are_not_blanket_successes(sel for key in ('root_functions', 'portable_root_functions', 'wasi_root_functions'): self.assertEqual(policy[key]['JSONNavigationTests'], 47) self.assertEqual(policy[key]['JSONSchemaExamplesTests'], 98) - self.assertEqual(policy[key]['JSONInspectionTests'], 76) + self.assertEqual(policy[key]['JSONInspectionTests'], 108) self.assertEqual(policy[key]['JSONIntegrationTests'], 32) for key in ('shared_state_functions', 'portable_shared_state_functions'): self.assertEqual(policy[key]['JSONNavigationTests'], 1) self.assertEqual(policy[key]['JSONSchemaExamplesTests'], 4) - self.assertEqual(policy[key]['JSONInspectionTests'], 4) + self.assertEqual(policy[key]['JSONInspectionTests'], 5) self.assertEqual(policy[key]['JSONIntegrationTests'], 11) def test_private_projection_identities_cannot_be_missing_renamed_or_duplicated(self): @@ -95,6 +96,23 @@ def test_private_projection_identities_cannot_be_missing_renamed_or_duplicated(s self.assertTrue(check_projection_cases({'function_names': shared}, shared=True)) self.assertFalse(check_projection_cases({'function_names': []}, shared=True)) + def test_graph_identity_and_compiler_receipts_reject_false_success(self): + names=sorted(GRAPH_FUNCTIONS) + self.assertEqual(len(names),32) + self.assertTrue(check_graph_cases({'function_names':names})) + for broken in [names[:-1],names+names[:1],['WrongModule.'+names[0].split('.',1)[1]]+names[1:]]: + self.assertFalse(check_graph_cases({'function_names':broken})) + for context,count,negatives in [(True,18,8),(False,28,18)]: + commands=[dict(name=str(n),status='passed',compiler_crash=False,exit_code=1 if n diff --git a/Sources/JSONInspection/Graph/JSONGraphProfile.swift b/Sources/JSONInspection/Graph/JSONGraphProfile.swift new file mode 100644 index 0000000..3ab48e4 --- /dev/null +++ b/Sources/JSONInspection/Graph/JSONGraphProfile.swift @@ -0,0 +1,42 @@ +// SPDX-License-Identifier: Apache-2.0 +import JSON +import JSONNavigation + +/// Experimental explicit direct-field profile; all locations are absolute/typed. +package struct JSONGraphProfile: Sendable { + package let nodesAt: [JSONLocationComponent] + package let edgesAt: [JSONLocationComponent] + package let nodeIDKey: JSONKey + package let edgeSourceKey: JSONKey + package let edgeTargetKey: JSONKey + package init(nodesAt: [JSONLocationComponent], edgesAt: [JSONLocationComponent], + nodeIDKey: JSONKey, edgeSourceKey: JSONKey, edgeTargetKey: JSONKey) { + self.nodesAt = nodesAt; self.edgesAt = edgesAt; self.nodeIDKey = nodeIDKey + self.edgeSourceKey = edgeSourceKey; self.edgeTargetKey = edgeTargetKey + } +} + +/// Graph semantic refusals retain exact original locations, never normalized IDs. +package enum JSONGraphFailure: Error, Sendable, Equatable { + case nodeLimitExceeded(limit: Int) + case edgeLimitExceeded(limit: Int) + case rowNotObject(at: [JSONLocationComponent], kind: JSONTraversalKind) + case idMissing(at: [JSONLocationComponent]) + case idNull(at: [JSONLocationComponent]) + case idWrongKind(at: [JSONLocationComponent], kind: JSONTraversalKind) + case duplicateNodeID(first: [JSONLocationComponent], second: [JSONLocationComponent]) + case danglingEndpoint(at: [JSONLocationComponent]) + case duplicateDirectedEdge(first: [JSONLocationComponent], second: [JSONLocationComponent]) + case nodeOutOfBounds(Int) + case edgeOutOfBounds(Int) + case frontierLimitExceeded(limit: Int) + case visitedLimitExceeded(limit: Int) + case resultLimitExceeded(limit: Int) +} + +/// Trusted row ordinals in this captured source, not durable graph identifiers. +package struct JSONGraphEdgeRecord: Sendable, Equatable { + package let row: Int + package let source: Int + package let target: Int +} diff --git a/Sources/JSONInspection/Graph/JSONGraphProjection.swift b/Sources/JSONInspection/Graph/JSONGraphProjection.swift new file mode 100644 index 0000000..23658bb --- /dev/null +++ b/Sources/JSONInspection/Graph/JSONGraphProjection.swift @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: Apache-2.0 +import JSON +import JSONNavigation + +/// Experimental immutable source-attached directed simple graph. Not public API. +package struct JSONGraphProjection: Sendable { + package let document: JSONDocument + package let profile: JSONGraphProfile + package let nodeIDs: [String] + package let edges: [JSONGraphEdgeRecord] + package let offsets: [Int] + package let adjacency: [Int] + package let traversal: JSONTraversalSummary + package var nodeCount: Int { nodeIDs.count } + package var edgeCount: Int { edges.count } + fileprivate init(document: JSONDocument, profile: JSONGraphProfile, nodeIDs: [String], + edges: [JSONGraphEdgeRecord], offsets: [Int], adjacency: [Int], + traversal: JSONTraversalSummary) { + self.document = document; self.profile = profile; self.nodeIDs = nodeIDs + self.edges = edges; self.offsets = offsets; self.adjacency = adjacency; self.traversal = traversal + } + + /// Complete source admission, nodes, edges and adjacency share one original meter. + package static func build(document: JSONDocument, profile: JSONGraphProfile, + limits: JSONLimits = .balanced, maximumNodes: Int = 10_000, + maximumEdges: Int = 50_000) throws -> JSONOperationReport { + guard maximumNodes >= 0, maximumEdges >= 0 else { throw JSONFailure(.configuration, code: .invalidLimits) } + var reducer = GraphBuilder(profile: profile, limits: limits, maximumNodes: maximumNodes, maximumEdges: maximumEdges) + return try JSONTraversal(document: document, limits: limits).inspectOwned( + scope: .virtual(roots: [profile.nodesAt, profile.edgesAt], overlap: .rejectAmbiguous), + options: .init(depthLimit: 1), reducer: &reducer) + } + + /// A new whole-source operation; returns original physical outgoing edge ordinals. + package func outgoingEdges(of node: Int, limits: JSONLimits = .balanced, maximumResults: Int = 50_000) + throws -> JSONOperationReport<[Int]> { + guard maximumResults >= 0 else { throw JSONFailure(.configuration, code: .invalidLimits) } + try checkNode(node) + var reducer = GraphQuery(graph: self, query: .outgoing(node), limits: limits, + maximumFrontier: 0, maximumVisited: 0, maximumResults: maximumResults) + return try JSONTraversal(document: document, limits: limits) + .inspectOwned(options: .init(depthLimit: 0), reducer: &reducer) + } + + /// Complete reachable component, including start; never a partial success. + package func breadthFirst(fromNode node: Int, limits: JSONLimits = .balanced, + maximumFrontier: Int = 10_000, maximumVisited: Int = 10_000, + maximumResults: Int = 10_000) throws -> JSONOperationReport<[Int]> { + guard maximumFrontier >= 0, maximumVisited >= 0, maximumResults >= 0 else { + throw JSONFailure(.configuration, code: .invalidLimits) + } + try checkNode(node) + var reducer = GraphQuery(graph: self, query: .breadthFirst(node), limits: limits, + maximumFrontier: maximumFrontier, maximumVisited: maximumVisited, + maximumResults: maximumResults) + return try JSONTraversal(document: document, limits: limits) + .inspectOwned(options: .init(depthLimit: 0), reducer: &reducer) + } + + package func nodeValue(at node: Int, limits: JSONLimits = .balanced) throws -> JSONOperationReport { + try checkNode(node) + return try value(at: profile.nodesAt, row: node, limits: limits) + } + package func edgeValue(at edge: Int, limits: JSONLimits = .balanced) throws -> JSONOperationReport { + guard edge >= 0, edge < edgeCount else { throw JSONGraphFailure.edgeOutOfBounds(edge) } + return try value(at: profile.edgesAt, row: edge, limits: limits) + } + /// Source-map lookup under a new explicit full-source admission. + package func nodeSpan(at node: Int, limits: JSONLimits = .balanced) throws -> JSONOperationReport { + try checkNode(node) + let scope = try JSONReadScope(document, limits: limits); defer { scope.invalidate() } + let state = try scope.check(); let path = try rowPath(profile.nodesAt, row: node, scope: scope, state: state) + let span = try document.sourceMap?.span(at: path.components, kind: .value, meter: &state.meter) + return try scope.complete(span) + } + private func checkNode(_ node: Int) throws { + guard node >= 0, node < nodeCount else { throw JSONGraphFailure.nodeOutOfBounds(node) } + } + private func value(at root: [JSONLocationComponent], row: Int, limits: JSONLimits) throws -> JSONOperationReport { + let scope = try JSONReadScope(document, limits: limits); defer { scope.invalidate() } + let state = try scope.check(); let path = try rowPath(root, row: row, scope: scope, state: state) + let value = try scope.run(path.components) { try scope.select(path.components, state: $0) } + // A returned JSON alias remains owned through handoff, independently of input. + try JSONAdmission.value(value, meter: &state.meter, owned: true) + return try scope.complete(value) + } + private func rowPath(_ root: [JSONLocationComponent], row: Int, scope: JSONReadScope, + state: JSONCodingState) throws -> JSONReadPath { + var path = JSONReadPath(scope: scope, components: [], bytes: 0) + for part in root { try state.meter.work(); path = try scope.append(part, to: path.components, state: state) } + return try scope.append(.index(row), to: path.components, state: state) + } +} + +internal enum GraphWorkStage: Sendable, Equatable { case nodeIdentity, endpointIdentity, edgePair, adjacency, breadthFirst } + +internal struct GraphBuilder: JSONTraversalOwnedReducer { + /// Qualification-only phase marker; no scope/capability is exposed. + var workObserver: (@Sendable (GraphWorkStage) -> Void)? = nil + let profile: JSONGraphProfile + let limits: JSONLimits + let maximumNodes: Int + let maximumEdges: Int + var ids: [String] = [] + var edges: [JSONGraphEdgeRecord] = [] + var nodeTotal = 0, edgeTotal = 0 + + mutating func observe(_ hit: JSONTraversalHit, value: JSON, + context: inout JSONTraversalContext) throws -> JSONTraversalAction { + try context.work() + if hit.relativeDepth == 0 { + let count = try context.arrayCount(value, at: hit.absoluteLocation) + if hit.scopeRootIndex == 0 { + guard count <= maximumNodes else { throw JSONGraphFailure.nodeLimitExceeded(limit: maximumNodes) } + nodeTotal = count + _ = try ProjectionAccounting.next(count, limits: limits) + try context.retain(256) + _ = try context.retainLocation(profile.nodesAt); _ = try context.retainLocation(profile.edgesAt) + for key in [profile.nodeIDKey, profile.edgeSourceKey, profile.edgeTargetKey] { + try context.work(); try context.retain(32); try context.retainKey(key, admittingCallerInput: true) + } + } else { + guard count <= maximumEdges else { throw JSONGraphFailure.edgeLimitExceeded(limit: maximumEdges) } + edgeTotal = count + } + return .continue + } + guard case .object = value else { throw JSONGraphFailure.rowNotObject(at: try context.retainLocation(hit.absoluteLocation), kind: hit.kind) } + if hit.scopeRootIndex == 0 { + let id = try string(value, key: profile.nodeIDKey, at: hit.absoluteLocation, context: &context) + for prior in ids.indices { + try context.work() + workObserver?(.nodeIdentity) + if try context.equalStrings(ids[prior], id) { + throw JSONGraphFailure.duplicateNodeID( + first: try context.appending(.index(prior), to: profile.nodesAt), + second: try context.retainLocation(hit.absoluteLocation)) + } + } + _ = try ProjectionAccounting.next(ids.count, limits: limits) + try context.retain(16); ids.append(id) + } else { + // Required fields are checked source then target before resolving either. + let source = try string(value, key: profile.edgeSourceKey, at: hit.absoluteLocation, context: &context) + let target = try string(value, key: profile.edgeTargetKey, at: hit.absoluteLocation, context: &context) + let from = try resolve(source, key: profile.edgeSourceKey, at: hit.absoluteLocation, context: &context) + let to = try resolve(target, key: profile.edgeTargetKey, at: hit.absoluteLocation, context: &context) + for prior in edges.indices { + try context.work() + workObserver?(.edgePair) + if edges[prior].source == from, edges[prior].target == to { + throw JSONGraphFailure.duplicateDirectedEdge( + first: try context.appending(.index(prior), to: profile.edgesAt), + second: try context.retainLocation(hit.absoluteLocation)) + } + } + _ = try ProjectionAccounting.next(edges.count, limits: limits) + try context.retain(24); edges.append(.init(row: edges.count, source: from, target: to)) + try context.releaseStringAlias(source); try context.releaseStringAlias(target) + } + return .skipDescendants + } + private func string(_ value: JSON, key: JSONKey, at row: [JSONLocationComponent], + context: inout JSONTraversalContext) throws -> String { + let member = try context.directStringMember(value, key: key, at: row) + if let text = member.text { return text } + let path = try context.appending(.key(key), to: row) + guard let kind = member.kind else { throw JSONGraphFailure.idMissing(at: path) } + if kind == .null { throw JSONGraphFailure.idNull(at: path) } + throw JSONGraphFailure.idWrongKind(at: path, kind: kind) + } + private func resolve(_ text: String, key: JSONKey, at row: [JSONLocationComponent], + context: inout JSONTraversalContext) throws -> Int { + for index in ids.indices { + try context.work() + workObserver?(.endpointIdentity) + if try context.equalStrings(ids[index], text) { return index } + } + throw JSONGraphFailure.danglingEndpoint(at: try context.appending(.key(key), to: row)) + } + mutating func finish(_ summary: JSONTraversalSummary, context: inout JSONTraversalContext) throws -> JSONGraphProjection { + guard ids.count == nodeTotal, edges.count == edgeTotal else { throw JSONFailure(.configuration, code: .invalidOperation) } + var offsets: [Int] = [], adjacency: [Int] = [] + try context.work(); try context.retain(8); offsets.append(0) + // Deterministic source-ordered CSR, deliberately bounded linear scans. + for node in ids.indices { + try context.work() + for edge in edges.indices { + workObserver?(.adjacency) + try context.work() + if edges[edge].source == node { + _ = try ProjectionAccounting.next(adjacency.count, limits: limits) + try context.retain(8); adjacency.append(edge) + } + } + _ = try ProjectionAccounting.next(offsets.count, limits: limits) + try context.retain(8); offsets.append(adjacency.count) + } + try context.work() + let document = try context.transferDocumentOwnership() + return .init(document: document, profile: profile, nodeIDs: ids, edges: edges, + offsets: offsets, adjacency: adjacency, traversal: summary) + } +} diff --git a/Sources/JSONInspection/Graph/JSONGraphQuery.swift b/Sources/JSONInspection/Graph/JSONGraphQuery.swift new file mode 100644 index 0000000..e23bc48 --- /dev/null +++ b/Sources/JSONInspection/Graph/JSONGraphQuery.swift @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: Apache-2.0 +import JSON +import JSONNavigation + +internal struct GraphQuery: JSONTraversalOwnedReducer { + var workObserver: (@Sendable (GraphWorkStage) -> Void)? = nil + enum Query { case outgoing(Int), breadthFirst(Int) } + let graph: JSONGraphProjection + let query: Query + let limits: JSONLimits + let maximumFrontier: Int + let maximumVisited: Int + let maximumResults: Int + mutating func observe(_ hit: JSONTraversalHit, value: JSON, + context: inout JSONTraversalContext) throws -> JSONTraversalAction { + try context.work(); return .skipDescendants + } + mutating func finish(_ summary: JSONTraversalSummary, context: inout JSONTraversalContext) throws -> [Int] { + let aliases = try retainAliases(context: &context) + var result: [Int] = [] + switch query { + case .outgoing(let node): + for position in graph.offsets[node].. UInt64 { + var total: UInt64 = 0 + func retain(_ bytes: UInt64, context: inout JSONTraversalContext) throws { + try context.retain(bytes) + // Each successful retain is bounded below UInt64.max by the core. + total += bytes + } + try context.work(); try retain(256, context: &context) + for path in [graph.profile.nodesAt, graph.profile.edgesAt] { + for part in path { + try context.work(); try retain(32, context: &context) + if case .key(let key) = part { + for _ in key.string.utf8 { try context.work(); try retain(1, context: &context) } + } + } + } + for key in [graph.profile.nodeIDKey, graph.profile.edgeSourceKey, graph.profile.edgeTargetKey] { + try context.work(); try retain(32, context: &context) + for _ in key.string.utf8 { try context.work(); try retain(1, context: &context) } + } + for id in graph.nodeIDs { + try context.work(); try retain(48, context: &context) + for _ in id.utf8 { try context.work(); try retain(1, context: &context) } + } + for _ in graph.edges { try context.work(); try retain(24, context: &context) } + for _ in graph.offsets { try context.work(); try retain(8, context: &context) } + for _ in graph.adjacency { try context.work(); try retain(8, context: &context) } + return total + } +} diff --git a/Sources/JSONNavigation/JSONTraversalContext.swift b/Sources/JSONNavigation/JSONTraversalContext.swift index a08e58e..4c9289a 100644 --- a/Sources/JSONNavigation/JSONTraversalContext.swift +++ b/Sources/JSONNavigation/JSONTraversalContext.swift @@ -34,6 +34,34 @@ package struct JSONTraversalContext: ~Copyable { package mutating func equalKeys(_ lhs: JSONKey, _ rhs: JSONKey) throws -> Bool { try perform { try JSONComparison.textEquals(lhs.string, rhs.string, meter: &$0.meter) } } + /// Scalar-exact comparison charged to the original admitted scope. + package mutating func equalStrings(_ lhs: String, _ rhs: String) throws -> Bool { + try perform { try JSONComparison.textEquals(lhs, rhs, meter: &$0.meter) } + } + /// Direct-member string selection. Nil kind means absent; null and other + /// kinds remain distinguishable. A returned string owns 32+UTF8 logical bytes. + /// No JSONKey hashing, second admission, cursor or ledger is created. + package mutating func directStringMember(_ value: JSON, key: JSONKey, + at path: [JSONLocationComponent]) throws + -> (text: String?, kind: JSONTraversalKind?) { + try perform(at: path) { state in + try JSONAdmission.text(key.string, key: true, meter: &state.meter) + try state.meter.work() + guard case .object(let object) = value else { throw JSONFailure(.typeMismatch, code: .wrongKind) } + guard let member = try object.lookup(key.string, meter: &state.meter) else { return (nil, nil) } + guard case .string(let text) = member else { return (nil, JSONTraversalKind(member)) } + try state.meter.retain(32) + for _ in text.utf8 { try state.meter.work(); try state.meter.retain(1) } + return (text, .string) + } + } + /// Relinquish a directStringMember alias after its last owned use. + package mutating func releaseStringAlias(_ text: String) throws { + try perform { state in + for _ in text.utf8 { try state.meter.work(); try state.meter.release(1) } + try state.meter.release(32) + } + } package mutating func arrayCount(_ value: JSON, at path: [JSONLocationComponent]) throws -> Int { try perform(at: path) { state in try state.meter.work() @@ -42,8 +70,9 @@ package struct JSONTraversalContext: ~Copyable { } } /// Reserve an additional owned alias, not another text admission or key model. - package mutating func retainKey(_ key: JSONKey) throws { + package mutating func retainKey(_ key: JSONKey, admittingCallerInput: Bool = false) throws { try perform { state in + if admittingCallerInput { try JSONAdmission.text(key.string, key: true, meter: &state.meter) } for _ in key.string.utf8 { try state.meter.work(); try state.meter.retain(1) } } } diff --git a/Tests/JSONInspectionTests/GraphAcceptanceTests.swift b/Tests/JSONInspectionTests/GraphAcceptanceTests.swift new file mode 100644 index 0000000..f98d2e3 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphAcceptanceTests.swift @@ -0,0 +1,382 @@ +// SPDX-License-Identifier: Apache-2.0 +import Foundation +import Synchronization +import Testing +import JSON +import JSONNavigation +@testable import JSONInspection + +private let graphProfile = JSONGraphProfile(nodesAt: [.key("nodes")], edgesAt: [.key("edges")], + nodeIDKey: "id", edgeSourceKey: "from", edgeTargetKey: "to") +private func graphDocument(_ name: String) throws -> JSONDocument { + // Match the existing P1 loader: WASI Foundation named lookup truncates extensions. + let root = try #require(Bundle.module.resourceURL) + let url = URL(fileURLWithPath: root.path + "/GraphFixtures/" + name + ".json") + return try JSONParser().parseDocument(Data(contentsOf: url)) +} +private func graphBuild(_ name: String, profile: JSONGraphProfile = graphProfile) throws -> JSONOperationReport { + try JSONGraphProjection.build(document: graphDocument(name), profile: profile) +} +private func graphFailure(_ body: () throws -> Void) -> JSONGraphFailure? { + do { try body(); Issue.record("Expected graph refusal"); return nil } + catch let error as JSONGraphFailure { return error } + catch { Issue.record("Unexpected error: \(error)"); return nil } +} +private func graphCore(_ body: () throws -> Void) -> JSONFailure? { + do { try body(); Issue.record("Expected core refusal"); return nil } + catch let error as JSONFailure { return error } + catch let error as JSONAccessFailure { return error.cause } + catch { Issue.record("Unexpected error: \(error)"); return nil } +} +@Test func graphG01EmptySourceAndNoValidStart() throws { + let graph = try graphBuild("g01-empty").result + #expect(graph.nodeCount == 0 && graph.edgeCount == 0 && graph.offsets == [0]) + #expect(graph.document.sourceBytes != nil) + #expect(graphFailure { _ = try graph.breadthFirst(fromNode: 0) } == .nodeOutOfBounds(0)) +} +@Test func graphG02IndependentOracleAndSourceOrder() throws { + struct Oracle: Decodable { let cases: [Case] } + struct Case: Decodable { let name: String; let expected: Expected } + struct Expected: Decodable { + let kind: String; let ids_codepoints: [[UInt32]]?; let edge_pairs: [[Int]]? + let outgoing_edge_ordinals: [[Int]]?; let bfs_from_node0: [Int]? + } + let bundle = try #require(Bundle.module.resourceURL) + let url = URL(fileURLWithPath: bundle.path + "/GraphFixtures/GRAPH-FIXTURE-ORACLE.json") + let oracle = try JSONValueDecoder().decode(Oracle.self, from: JSON.parse(Data(contentsOf: url))) + var evaluated = 0 + for entry in oracle.cases where entry.expected.kind == "valid" { + let profile = entry.name == "g23-numeric-object-key" ? JSONGraphProfile( + nodesAt: [.key("0"), .key("nodes")], edgesAt: [.key("0"), .key("edges")], + nodeIDKey: "key/id", edgeSourceKey: "src~", edgeTargetKey: "dst") : graphProfile + let graph = try graphBuild(entry.name, profile: profile).result + #expect(graph.nodeIDs.map { $0.unicodeScalars.map(\.value) } == entry.expected.ids_codepoints) + #expect(graph.edges.map { [$0.source, $0.target] } == entry.expected.edge_pairs) + var outgoing: [[Int]] = [] + for node in 0.. 0 { #expect(try graph.breadthFirst(fromNode: 0).result == entry.expected.bfs_from_node0) } + let document = graph.document + #expect(try JSONGraphProjection.build(document: document, profile: profile, maximumNodes: graph.nodeCount, maximumEdges: graph.edgeCount).result.edges == graph.edges) + if graph.nodeCount > 0 { + #expect(graphFailure { _ = try JSONGraphProjection.build(document: document, profile: profile, maximumNodes: graph.nodeCount - 1) } == .nodeLimitExceeded(limit: graph.nodeCount - 1)) + let reached = try #require(entry.expected.bfs_from_node0) + var seen = Set([0]), queue = [0], head = 0, peak = 1 + while head < queue.count { + let node = queue[head]; head += 1 + for pair in try #require(entry.expected.edge_pairs) where pair[0] == node { + if seen.insert(pair[1]).inserted { queue.append(pair[1]); peak = max(peak, queue.count - head) } + } + } + #expect(queue == reached) + #expect(try graph.breadthFirst(fromNode: 0, maximumFrontier: peak, maximumVisited: reached.count, maximumResults: reached.count).result == reached) + #expect(graphFailure { _ = try graph.breadthFirst(fromNode: 0, maximumFrontier: peak - 1) } == .frontierLimitExceeded(limit: peak - 1)) + #expect(graphFailure { _ = try graph.breadthFirst(fromNode: 0, maximumVisited: reached.count - 1) } == .visitedLimitExceeded(limit: reached.count - 1)) + #expect(graphFailure { _ = try graph.breadthFirst(fromNode: 0, maximumResults: reached.count - 1) } == .resultLimitExceeded(limit: reached.count - 1)) + } + if graph.edgeCount > 0 { + #expect(graphFailure { _ = try JSONGraphProjection.build(document: document, profile: profile, maximumEdges: graph.edgeCount - 1) } == .edgeLimitExceeded(limit: graph.edgeCount - 1)) + } + evaluated += 1 + } + #expect(evaluated == 12) + var refused = 0 + for entry in oracle.cases where entry.expected.kind != "valid" { + if entry.expected.kind == "overlap" { + let profile = JSONGraphProfile(nodesAt: [.key("data")], edgesAt: [.key("data")], nodeIDKey: "id", edgeSourceKey: "from", edgeTargetKey: "to") + #expect(throws: JSONTraversalFailure.ambiguousRoots(first: 0, second: 1)) { _ = try graphBuild(entry.name, profile: profile) } + } else if entry.expected.kind.hasSuffix("not_array") { + #expect(graphCore { _ = try graphBuild(entry.name) }?.code == .wrongKind) + } else { + let failure = graphFailure { _ = try graphBuild(entry.name) } + switch (entry.expected.kind, failure) { + case ("duplicate_node_id", .duplicateNodeID), ("node_id_missing", .idMissing), + ("edge_source_missing", .idMissing), ("node_id_null", .idNull), + ("edge_target_null", .idNull), ("node_id_wrong_kind", .idWrongKind), + ("edge_target_wrong_kind", .idWrongKind), ("node_row_wrong_kind", .rowNotObject), + ("edge_row_wrong_kind", .rowNotObject), ("dangling_endpoint", .danglingEndpoint), + ("duplicate_directed_edge", .duplicateDirectedEdge): break + default: Issue.record("Independent refusal mismatch: \(entry.name) \(String(describing: failure))") + } + } + refused += 1 + } + #expect(refused == 15) + print("GRAPH_ORACLE passed valid=12 refused=15") + +} +@Test func graphG03ScalarDistinctIDs() throws { + let graph = try graphBuild("g03-unicode-scalars").result + #expect(graph.nodeCount == 2 && graph.edges[0].target == 1) + #expect(graph.nodeIDs[0] == graph.nodeIDs[1]) // Swift canonical equivalence is intentionally different. + #expect(Array(graph.nodeIDs[0].utf8) != Array(graph.nodeIDs[1].utf8)) +} +@Test func graphG04SpecialIDsAreNotPaths() throws { + let graph = try graphBuild("g04-empty-and-special-ids").result + #expect(graph.nodeIDs.contains("")); #expect(graph.nodeCount == 3) + for node in 0..= 6 { fired.withLock { $0 = true }; withUnsafeCurrentTask { $0?.cancel() } } + } + } + let observer: @Sendable (GraphWorkStage) -> Void = { current in + if current == phase { armed.withLock { $0 = true } } + } + if phase == .breadthFirst { + let captured = try JSONGraphProjection.build(document: large, profile: graphProfile).result + var query = GraphQuery(workObserver: observer, graph: captured, query: .breadthFirst(0), limits: limits, + maximumFrontier: 12, maximumVisited: 12, maximumResults: 12) + #expect(throws: CancellationError.self) { _ = try walk.inspectOwned(options: .init(depthLimit: 0), reducer: &query) } + } else { + var reducer = GraphBuilder(workObserver: observer, profile: graphProfile, limits: limits, maximumNodes: 12, maximumEdges: 11) + #expect(throws: CancellationError.self) { + _ = try walk.inspectOwned(scope: .virtual(roots: [graphProfile.nodesAt, graphProfile.edgesAt], overlap: .rejectAmbiguous), options: .init(depthLimit: 1), reducer: &reducer) + } + } + } + try await job.value + #expect(fired.withLock { $0 } && cleared.withLock { $0 }) + print("GRAPH_CANCELLATION phase=\(phase) checkpoints=\(ticks.withLock { $0 }) root-and-state-released=true") + } + let task = Task { + withUnsafeCurrentTask { $0?.cancel() } + #expect(throws: CancellationError.self) { _ = try JSONGraphProjection.build(document: doc, profile: graphProfile) } + #expect(throws: CancellationError.self) { _ = try graph.breadthFirst(fromNode: 0) } + } + await task.value + #expect(try graph.breadthFirst(fromNode: 0).result == [0, 1, 2, 3]) +} +@Test func graphG29FailurePrecedenceAndTerminalLatch() throws { + let doc = JSONDocument(root: try JSON.parse(#"{"nodes":[false,{"id":"A"},{"id":"A"}],"edges":[{"from":"X","to":"Y"}]}"#)) + #expect(graphCore { _ = try JSONGraphProjection.build(document: doc, profile: graphProfile, maximumNodes: -1) }?.code == .invalidLimits) + #expect(graphFailure { _ = try JSONGraphProjection.build(document: doc, profile: graphProfile) } == .rowNotObject(at: [.key("nodes"), .index(0)], kind: .boolean)) + var limits = JSONLimits.balanced; limits[.workUnits] = 0 + #expect(graphCore { _ = try JSONGraphProjection.build(document: doc, profile: graphProfile, limits: limits) }?.category == .resource) + let prefix = (0..<32).map { "{\"id\":\"n\($0)\"}" }.joined(separator: ",") + let late = JSONDocument(root: try JSON.parse("{\"nodes\":[" + prefix + ",{\"id\":\"n0\"}],\"edges\":[{\"from\":\"X\",\"to\":\"Y\"}]}")) + #expect(graphFailure { _ = try JSONGraphProjection.build(document: late, profile: graphProfile) } == .duplicateNodeID(first: [.key("nodes"), .index(0)], second: [.key("nodes"), .index(32)])) + #expect(graphFailure { _ = try JSONGraphProjection.build(document: late, profile: graphProfile, maximumNodes: 32) } == .nodeLimitExceeded(limit: 32)) + let admitted = try JSONTraversal(document: late).visit(options: .init(depthLimit: 0)) { _,_ in .continue } + limits = .balanced; limits[.workUnits] = admitted.statistics[.workUnits] + 100 + #expect(graphCore { _ = try JSONGraphProjection.build(document: late, profile: graphProfile, limits: limits) }?.category == .resource) + let fields = JSONDocument(root: try JSON.parse(#"{"nodes":[{"id":"A"}],"edges":[{"from":"MISSING","to":null}]}"#)) + #expect(graphFailure { _ = try JSONGraphProjection.build(document: fields, profile: graphProfile) } == .idNull(at: [.key("edges"), .index(0), .key("to")])) + struct Catcher: JSONTraversalOwnedReducer { + enum Replacement: Error { case caller } + mutating func observe(_ hit: JSONTraversalHit, value: JSON, context: inout JSONTraversalContext) throws -> JSONTraversalAction { + do { try context.work(UInt64.max) } catch {} + throw Replacement.caller + } + mutating func finish(_ summary: JSONTraversalSummary, context: inout JSONTraversalContext) throws -> Bool { true } + } + var reducer = Catcher() + #expect(graphCore { _ = try JSONTraversal(document: doc).inspectOwned(reducer: &reducer) }?.category == .resource) +} +@Test func graphG30ConcurrentViewsHaveIndependentHealth() async throws { + let doc = try graphDocument("g22-diamond") + let graph = try JSONGraphProjection.build(document: doc, profile: graphProfile).result + try await withThrowingTaskGroup(of: [Int].self) { group in + for _ in 0..<12 { + group.addTask { + #expect(graphFailure { _ = try graph.breadthFirst(fromNode: 0, maximumResults: 1) } == .resultLimitExceeded(limit: 1)) + return try JSONGraphProjection.build(document: doc, profile: graphProfile).result.breadthFirst(fromNode: 0).result + } + } + for try await result in group { #expect(result == [0, 1, 2, 3]) } + } +} +@Test func graphG31CapturedRootAndSourceMapCoherence() throws { + let original = try graphDocument("g02-simple"), graph = try JSONGraphProjection.build(document: original, profile: graphProfile).result + let replaced = original.replacingRoot(try JSON.parse(#"{"nodes":[{"id":"NEW"}],"edges":[]}"#)) + let new = try JSONGraphProjection.build(document: replaced, profile: graphProfile).result + #expect(try graph.nodeSpan(at: 0).result != nil && new.nodeSpan(at: 0).result == nil) + #expect(graph.nodeIDs == ["A", "B", "C"] && new.nodeIDs == ["NEW"]) + #expect(graph.document.sourceBytes == original.sourceBytes) +} +@Test func graphG32CheckedMetadataAndExistingPublicSemantics() throws { + let graph = try graphBuild("g02-simple").result + #expect(graphFailure { _ = try graph.nodeValue(at: -1) } == .nodeOutOfBounds(-1)) + #expect(graphFailure { _ = try graph.edgeValue(at: graph.edgeCount) } == .edgeOutOfBounds(graph.edgeCount)) + #expect(try graph.edgeValue(at: 0).result == JSONPointer(parsing: "/edges/0").resolve(in: graph.document.root)) + #expect(try JSONTraversal(document: graph.document).visit { _, _ in .continue }.statistics[.retainedBytes] == 0) + #expect(try JSONShapeInspector(document: graph.document).inspectArray(at: [.key("nodes")]).result.totalRows == 3) +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/GRAPH-FIXTURE-ORACLE.json b/Tests/JSONInspectionTests/GraphFixtures/GRAPH-FIXTURE-ORACLE.json new file mode 100644 index 0000000..b613cb8 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/GRAPH-FIXTURE-ORACLE.json @@ -0,0 +1,653 @@ +{ + "disclaimer": "Independent test-data oracle, NOT Swift-JSON validation", + "cases": [ + { + "name": "g01-empty", + "file": "g01-empty.json", + "sha256": "1ddf1d31190da6e68449f240aefa5e9b87896f675196008d26c8ea7431ec8b4c", + "profile": {}, + "expected": { + "kind": "valid", + "ids_codepoints": [], + "edge_pairs": [], + "outgoing_edge_ordinals": [], + "bfs_from_node0": [] + } + }, + { + "name": "g02-simple", + "file": "g02-simple.json", + "sha256": "97ce5fa7ec116a89a48606897295bbc2934fdc927b429a213d13e3474694f100", + "profile": {}, + "expected": { + "kind": "valid", + "ids_codepoints": [ + [ + 65 + ], + [ + 66 + ], + [ + 67 + ] + ], + "edge_pairs": [ + [ + 0, + 1 + ], + [ + 0, + 2 + ], + [ + 1, + 2 + ] + ], + "outgoing_edge_ordinals": [ + [ + 0, + 1 + ], + [ + 2 + ], + [] + ], + "bfs_from_node0": [ + 0, + 1, + 2 + ] + } + }, + { + "name": "g03-unicode-scalars", + "file": "g03-unicode-scalars.json", + "sha256": "47cf0ab06c0b72d663d8a7d807f46752414e34cb422ef526a4e155a60e194182", + "profile": {}, + "expected": { + "kind": "valid", + "ids_codepoints": [ + [ + 233 + ], + [ + 101, + 769 + ] + ], + "edge_pairs": [ + [ + 0, + 1 + ] + ], + "outgoing_edge_ordinals": [ + [ + 0 + ], + [] + ], + "bfs_from_node0": [ + 0, + 1 + ] + } + }, + { + "name": "g04-empty-and-special-ids", + "file": "g04-empty-and-special-ids.json", + "sha256": "45a06cd463be2eee805f145d4a386ab8f76d64bf53503aa5086df16724c02791", + "profile": {}, + "expected": { + "kind": "valid", + "ids_codepoints": [ + [], + [ + 97, + 47, + 98, + 126, + 99 + ], + [ + 48 + ] + ], + "edge_pairs": [ + [ + 0, + 1 + ], + [ + 2, + 0 + ] + ], + "outgoing_edge_ordinals": [ + [ + 0 + ], + [], + [ + 1 + ] + ], + "bfs_from_node0": [ + 0, + 1 + ] + } + }, + { + "name": "g05-duplicate-id", + "file": "g05-duplicate-id.json", + "sha256": "ebb7b9720976636fc4521f6b66c1e92544bae4ab1dfb244c4988af42cc56eb24", + "profile": {}, + "expected": { + "kind": "duplicate_node_id", + "node_index": 1, + "first_index": 0 + } + }, + { + "name": "g06-equal-payload-distinct-id", + "file": "g06-equal-payload-distinct-id.json", + "sha256": "f740b12b7a01d74c2b86b729e56c82abc5b7ef7be2e8672313286bf18e295ddf", + "profile": {}, + "expected": { + "kind": "valid", + "ids_codepoints": [ + [ + 65 + ], + [ + 66 + ] + ], + "edge_pairs": [], + "outgoing_edge_ordinals": [ + [], + [] + ], + "bfs_from_node0": [ + 0 + ] + } + }, + { + "name": "g07-node-id-missing", + "file": "g07-node-id-missing.json", + "sha256": "3473660136a3b01aa468cbc5a1c1f1f093ae0d9ac476292a9c44f79e296376f1", + "profile": {}, + "expected": { + "kind": "node_id_missing", + "node_index": 0 + } + }, + { + "name": "g08-node-id-null", + "file": "g08-node-id-null.json", + "sha256": "ad53d92263bb921749296c96f50470495a3758646e3777a4039c15a2dd4f548c", + "profile": {}, + "expected": { + "kind": "node_id_null", + "node_index": 0 + } + }, + { + "name": "g09-node-id-number", + "file": "g09-node-id-number.json", + "sha256": "311961bc71d065686c2be6ddc4b3bae46c1ffab1c5906fc4c0b8c22af13e3d37", + "profile": {}, + "expected": { + "kind": "node_id_wrong_kind", + "node_index": 0 + } + }, + { + "name": "g10-node-row-not-object", + "file": "g10-node-row-not-object.json", + "sha256": "d76618e34d1bc43d9de376b72d2a2f8cc4ed2d449b6fdc8537c901147fba4212", + "profile": {}, + "expected": { + "kind": "node_row_wrong_kind", + "node_index": 0 + } + }, + { + "name": "g11-edge-source-missing", + "file": "g11-edge-source-missing.json", + "sha256": "77e0e2a123ee05f3d842b00181e2463042ac9580169c6571d79146b1aebf988c", + "profile": {}, + "expected": { + "kind": "edge_source_missing", + "edge_index": 0 + } + }, + { + "name": "g12-edge-target-null", + "file": "g12-edge-target-null.json", + "sha256": "b873ad0efe1efe13215a283ec202c55407aeb1d754e8aba79dd38f72c3529244", + "profile": {}, + "expected": { + "kind": "edge_target_null", + "edge_index": 0 + } + }, + { + "name": "g13-edge-target-number", + "file": "g13-edge-target-number.json", + "sha256": "2bbbedb9d505d32ca01228c5b63ba71ee8510299371757ad741cc17c09a5e49a", + "profile": {}, + "expected": { + "kind": "edge_target_wrong_kind", + "edge_index": 0 + } + }, + { + "name": "g14-edge-row-not-object", + "file": "g14-edge-row-not-object.json", + "sha256": "21de4ba95ef61f4856d0eee1a02d0c17694b50cc291378fdcb05efbf6e721e01", + "profile": {}, + "expected": { + "kind": "edge_row_wrong_kind", + "edge_index": 0 + } + }, + { + "name": "g15-dangling", + "file": "g15-dangling.json", + "sha256": "c3a9c699115be44b60da5d165544ea9a4f9d443685422f5ab99fdee59a2d8c54", + "profile": {}, + "expected": { + "kind": "dangling_endpoint", + "edge_index": 0 + } + }, + { + "name": "g15b-dangling-empty", + "file": "g15b-dangling-empty.json", + "sha256": "074f22438edb27a566aba8491502909f894e0aefdf5dac04dd589758b0844d20", + "profile": {}, + "expected": { + "kind": "dangling_endpoint", + "edge_index": 0 + } + }, + { + "name": "g16-duplicate-edge", + "file": "g16-duplicate-edge.json", + "sha256": "76bffe5363e00f24cb4e6951eff04214d11170682e80e8e53cbd7ab1c321419e", + "profile": {}, + "expected": { + "kind": "duplicate_directed_edge", + "edge_index": 1, + "first_index": 0 + } + }, + { + "name": "g17-reverse-edges", + "file": "g17-reverse-edges.json", + "sha256": "62bc74cf4934b0c2f82d322b8f28c563dd70c1745df6661aaa1389caef55c780", + "profile": {}, + "expected": { + "kind": "valid", + "ids_codepoints": [ + [ + 65 + ], + [ + 66 + ] + ], + "edge_pairs": [ + [ + 0, + 1 + ], + [ + 1, + 0 + ] + ], + "outgoing_edge_ordinals": [ + [ + 0 + ], + [ + 1 + ] + ], + "bfs_from_node0": [ + 0, + 1 + ] + } + }, + { + "name": "g18-self-edge", + "file": "g18-self-edge.json", + "sha256": "2bd68dc1cd578b9ac7b922c4fb2f13d2f50bfa6c4068af18966deb59e0205e8d", + "profile": {}, + "expected": { + "kind": "valid", + "ids_codepoints": [ + [ + 65 + ] + ], + "edge_pairs": [ + [ + 0, + 0 + ] + ], + "outgoing_edge_ordinals": [ + [ + 0 + ] + ], + "bfs_from_node0": [ + 0 + ] + } + }, + { + "name": "g19-cycle", + "file": "g19-cycle.json", + "sha256": "d0c0a81d2a87dfe714b963957f6b7305cdf2942df58ea2a2548aa450d1e0d349", + "profile": {}, + "expected": { + "kind": "valid", + "ids_codepoints": [ + [ + 65 + ], + [ + 66 + ], + [ + 67 + ] + ], + "edge_pairs": [ + [ + 0, + 1 + ], + [ + 1, + 2 + ], + [ + 2, + 0 + ] + ], + "outgoing_edge_ordinals": [ + [ + 0 + ], + [ + 1 + ], + [ + 2 + ] + ], + "bfs_from_node0": [ + 0, + 1, + 2 + ] + } + }, + { + "name": "g20-isolate", + "file": "g20-isolate.json", + "sha256": "5137753f500607e15e29d3fc5510b62db8e23c10b647a776419972db3768f098", + "profile": {}, + "expected": { + "kind": "valid", + "ids_codepoints": [ + [ + 65 + ], + [ + 66 + ], + [ + 73, + 83, + 79 + ] + ], + "edge_pairs": [ + [ + 0, + 1 + ] + ], + "outgoing_edge_ordinals": [ + [ + 0 + ], + [], + [] + ], + "bfs_from_node0": [ + 0, + 1 + ] + } + }, + { + "name": "g21-bfs-source-edge-order", + "file": "g21-bfs-source-edge-order.json", + "sha256": "73166cac74801e95d7954bcd7b39b1be9e990bd7bb5d730800a1d8c7d53b77ed", + "profile": {}, + "expected": { + "kind": "valid", + "ids_codepoints": [ + [ + 65 + ], + [ + 66 + ], + [ + 67 + ], + [ + 68 + ] + ], + "edge_pairs": [ + [ + 0, + 3 + ], + [ + 0, + 1 + ], + [ + 0, + 2 + ] + ], + "outgoing_edge_ordinals": [ + [ + 0, + 1, + 2 + ], + [], + [], + [] + ], + "bfs_from_node0": [ + 0, + 3, + 1, + 2 + ] + } + }, + { + "name": "g22-diamond", + "file": "g22-diamond.json", + "sha256": "8b8b2934a1bdd720621c8045c54dde4e6eee9a97ce4b90020b0404d4ff26cedf", + "profile": {}, + "expected": { + "kind": "valid", + "ids_codepoints": [ + [ + 65 + ], + [ + 66 + ], + [ + 67 + ], + [ + 68 + ] + ], + "edge_pairs": [ + [ + 0, + 1 + ], + [ + 0, + 2 + ], + [ + 1, + 3 + ], + [ + 2, + 3 + ] + ], + "outgoing_edge_ordinals": [ + [ + 0, + 1 + ], + [ + 2 + ], + [ + 3 + ], + [] + ], + "bfs_from_node0": [ + 0, + 1, + 2, + 3 + ] + } + }, + { + "name": "g23-numeric-object-key", + "file": "g23-numeric-object-key.json", + "sha256": "ae3d0720da528a24240b629891b1d2d47928efa901fbb3d7c29417affb942385", + "profile": { + "nodes_at": [ + { + "key": "0" + }, + { + "key": "nodes" + } + ], + "edges_at": [ + { + "key": "0" + }, + { + "key": "edges" + } + ], + "node_id_key": "key/id", + "edge_source_key": "src~", + "edge_target_key": "dst" + }, + "expected": { + "kind": "valid", + "ids_codepoints": [ + [ + 48 + ], + [ + 65 + ] + ], + "edge_pairs": [ + [ + 0, + 1 + ] + ], + "outgoing_edge_ordinals": [ + [ + 0 + ], + [] + ], + "bfs_from_node0": [ + 0, + 1 + ] + } + }, + { + "name": "g24-overlap", + "file": "g24-overlap.json", + "sha256": "74011485b9b4c4cf9fe70d264ffe7db4ba3796034968c0773b3213f395475e93", + "profile": { + "nodes_at": [ + { + "key": "data" + } + ], + "edges_at": [ + { + "key": "data" + } + ] + }, + "expected": { + "kind": "overlap" + } + }, + { + "name": "g25-wrong-nodes-array", + "file": "g25-wrong-nodes-array.json", + "sha256": "e91a8449e7af8b0555eaeb0b90087a580c72ae239dfb875c1c138dd128e9a8b1", + "profile": {}, + "expected": { + "kind": "nodes_not_array" + } + }, + { + "name": "g26-wrong-edges-array", + "file": "g26-wrong-edges-array.json", + "sha256": "96f22c1ec9ff60642dc39f4188c0955ceeb01da3a7bf9570cbe885ca59f012c1", + "profile": {}, + "expected": { + "kind": "edges_not_array" + } + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g01-empty.json b/Tests/JSONInspectionTests/GraphFixtures/g01-empty.json new file mode 100644 index 0000000..ecb3718 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g01-empty.json @@ -0,0 +1,4 @@ +{ + "nodes": [], + "edges": [] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g02-simple.json b/Tests/JSONInspectionTests/GraphFixtures/g02-simple.json new file mode 100644 index 0000000..72cefa8 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g02-simple.json @@ -0,0 +1,27 @@ +{ + "nodes": [ + { + "id": "A" + }, + { + "id": "B" + }, + { + "id": "C" + } + ], + "edges": [ + { + "from": "A", + "to": "B" + }, + { + "from": "A", + "to": "C" + }, + { + "from": "B", + "to": "C" + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g03-unicode-scalars.json b/Tests/JSONInspectionTests/GraphFixtures/g03-unicode-scalars.json new file mode 100644 index 0000000..1d0f4ab --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g03-unicode-scalars.json @@ -0,0 +1,16 @@ +{ + "nodes": [ + { + "id": "é" + }, + { + "id": "é" + } + ], + "edges": [ + { + "from": "é", + "to": "é" + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g04-empty-and-special-ids.json b/Tests/JSONInspectionTests/GraphFixtures/g04-empty-and-special-ids.json new file mode 100644 index 0000000..230b184 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g04-empty-and-special-ids.json @@ -0,0 +1,23 @@ +{ + "nodes": [ + { + "id": "" + }, + { + "id": "a/b~c" + }, + { + "id": "0" + } + ], + "edges": [ + { + "from": "", + "to": "a/b~c" + }, + { + "from": "0", + "to": "" + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g05-duplicate-id.json b/Tests/JSONInspectionTests/GraphFixtures/g05-duplicate-id.json new file mode 100644 index 0000000..f74f5cb --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g05-duplicate-id.json @@ -0,0 +1,13 @@ +{ + "nodes": [ + { + "id": "A", + "name": "x" + }, + { + "id": "A", + "name": "y" + } + ], + "edges": [] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g06-equal-payload-distinct-id.json b/Tests/JSONInspectionTests/GraphFixtures/g06-equal-payload-distinct-id.json new file mode 100644 index 0000000..709b3b0 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g06-equal-payload-distinct-id.json @@ -0,0 +1,17 @@ +{ + "nodes": [ + { + "id": "A", + "same": { + "x": 1 + } + }, + { + "id": "B", + "same": { + "x": 1 + } + } + ], + "edges": [] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g07-node-id-missing.json b/Tests/JSONInspectionTests/GraphFixtures/g07-node-id-missing.json new file mode 100644 index 0000000..6892396 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g07-node-id-missing.json @@ -0,0 +1,8 @@ +{ + "nodes": [ + { + "name": "A" + } + ], + "edges": [] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g08-node-id-null.json b/Tests/JSONInspectionTests/GraphFixtures/g08-node-id-null.json new file mode 100644 index 0000000..4cdcf9a --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g08-node-id-null.json @@ -0,0 +1,8 @@ +{ + "nodes": [ + { + "id": null + } + ], + "edges": [] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g09-node-id-number.json b/Tests/JSONInspectionTests/GraphFixtures/g09-node-id-number.json new file mode 100644 index 0000000..d15d147 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g09-node-id-number.json @@ -0,0 +1,8 @@ +{ + "nodes": [ + { + "id": 22 + } + ], + "edges": [] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g10-node-row-not-object.json b/Tests/JSONInspectionTests/GraphFixtures/g10-node-row-not-object.json new file mode 100644 index 0000000..4a98b06 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g10-node-row-not-object.json @@ -0,0 +1,6 @@ +{ + "nodes": [ + "A" + ], + "edges": [] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g11-edge-source-missing.json b/Tests/JSONInspectionTests/GraphFixtures/g11-edge-source-missing.json new file mode 100644 index 0000000..d3fa950 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g11-edge-source-missing.json @@ -0,0 +1,12 @@ +{ + "nodes": [ + { + "id": "A" + } + ], + "edges": [ + { + "to": "A" + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g12-edge-target-null.json b/Tests/JSONInspectionTests/GraphFixtures/g12-edge-target-null.json new file mode 100644 index 0000000..95b6210 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g12-edge-target-null.json @@ -0,0 +1,13 @@ +{ + "nodes": [ + { + "id": "A" + } + ], + "edges": [ + { + "from": "A", + "to": null + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g13-edge-target-number.json b/Tests/JSONInspectionTests/GraphFixtures/g13-edge-target-number.json new file mode 100644 index 0000000..b6e64fb --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g13-edge-target-number.json @@ -0,0 +1,13 @@ +{ + "nodes": [ + { + "id": "A" + } + ], + "edges": [ + { + "from": "A", + "to": 3 + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g14-edge-row-not-object.json b/Tests/JSONInspectionTests/GraphFixtures/g14-edge-row-not-object.json new file mode 100644 index 0000000..1872870 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g14-edge-row-not-object.json @@ -0,0 +1,10 @@ +{ + "nodes": [ + { + "id": "A" + } + ], + "edges": [ + 3 + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g15-dangling.json b/Tests/JSONInspectionTests/GraphFixtures/g15-dangling.json new file mode 100644 index 0000000..f70a7b2 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g15-dangling.json @@ -0,0 +1,13 @@ +{ + "nodes": [ + { + "id": "A" + } + ], + "edges": [ + { + "from": "A", + "to": "B" + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g15b-dangling-empty.json b/Tests/JSONInspectionTests/GraphFixtures/g15b-dangling-empty.json new file mode 100644 index 0000000..d7ec452 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g15b-dangling-empty.json @@ -0,0 +1,9 @@ +{ + "nodes": [], + "edges": [ + { + "from": "A", + "to": "A" + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g16-duplicate-edge.json b/Tests/JSONInspectionTests/GraphFixtures/g16-duplicate-edge.json new file mode 100644 index 0000000..2aa7a8d --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g16-duplicate-edge.json @@ -0,0 +1,20 @@ +{ + "nodes": [ + { + "id": "A" + }, + { + "id": "B" + } + ], + "edges": [ + { + "from": "A", + "to": "B" + }, + { + "from": "A", + "to": "B" + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g17-reverse-edges.json b/Tests/JSONInspectionTests/GraphFixtures/g17-reverse-edges.json new file mode 100644 index 0000000..8fb4dfc --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g17-reverse-edges.json @@ -0,0 +1,20 @@ +{ + "nodes": [ + { + "id": "A" + }, + { + "id": "B" + } + ], + "edges": [ + { + "from": "A", + "to": "B" + }, + { + "from": "B", + "to": "A" + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g18-self-edge.json b/Tests/JSONInspectionTests/GraphFixtures/g18-self-edge.json new file mode 100644 index 0000000..140674c --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g18-self-edge.json @@ -0,0 +1,13 @@ +{ + "nodes": [ + { + "id": "A" + } + ], + "edges": [ + { + "from": "A", + "to": "A" + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g19-cycle.json b/Tests/JSONInspectionTests/GraphFixtures/g19-cycle.json new file mode 100644 index 0000000..44c5e1c --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g19-cycle.json @@ -0,0 +1,27 @@ +{ + "nodes": [ + { + "id": "A" + }, + { + "id": "B" + }, + { + "id": "C" + } + ], + "edges": [ + { + "from": "A", + "to": "B" + }, + { + "from": "B", + "to": "C" + }, + { + "from": "C", + "to": "A" + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g20-isolate.json b/Tests/JSONInspectionTests/GraphFixtures/g20-isolate.json new file mode 100644 index 0000000..c3c485c --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g20-isolate.json @@ -0,0 +1,19 @@ +{ + "nodes": [ + { + "id": "A" + }, + { + "id": "B" + }, + { + "id": "ISO" + } + ], + "edges": [ + { + "from": "A", + "to": "B" + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g21-bfs-source-edge-order.json b/Tests/JSONInspectionTests/GraphFixtures/g21-bfs-source-edge-order.json new file mode 100644 index 0000000..01322f0 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g21-bfs-source-edge-order.json @@ -0,0 +1,30 @@ +{ + "nodes": [ + { + "id": "A" + }, + { + "id": "B" + }, + { + "id": "C" + }, + { + "id": "D" + } + ], + "edges": [ + { + "from": "A", + "to": "D" + }, + { + "from": "A", + "to": "B" + }, + { + "from": "A", + "to": "C" + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g22-diamond.json b/Tests/JSONInspectionTests/GraphFixtures/g22-diamond.json new file mode 100644 index 0000000..88d162e --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g22-diamond.json @@ -0,0 +1,34 @@ +{ + "nodes": [ + { + "id": "A" + }, + { + "id": "B" + }, + { + "id": "C" + }, + { + "id": "D" + } + ], + "edges": [ + { + "from": "A", + "to": "B" + }, + { + "from": "A", + "to": "C" + }, + { + "from": "B", + "to": "D" + }, + { + "from": "C", + "to": "D" + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g23-numeric-object-key.json b/Tests/JSONInspectionTests/GraphFixtures/g23-numeric-object-key.json new file mode 100644 index 0000000..b985501 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g23-numeric-object-key.json @@ -0,0 +1,18 @@ +{ + "0": { + "nodes": [ + { + "key/id": "0" + }, + { + "key/id": "A" + } + ], + "edges": [ + { + "src~": "0", + "dst": "A" + } + ] + } +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g24-overlap.json b/Tests/JSONInspectionTests/GraphFixtures/g24-overlap.json new file mode 100644 index 0000000..7e521c7 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g24-overlap.json @@ -0,0 +1,7 @@ +{ + "data": [ + { + "id": "A" + } + ] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g25-wrong-nodes-array.json b/Tests/JSONInspectionTests/GraphFixtures/g25-wrong-nodes-array.json new file mode 100644 index 0000000..a649225 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g25-wrong-nodes-array.json @@ -0,0 +1,4 @@ +{ + "nodes": {}, + "edges": [] +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/g26-wrong-edges-array.json b/Tests/JSONInspectionTests/GraphFixtures/g26-wrong-edges-array.json new file mode 100644 index 0000000..69e5bff --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/g26-wrong-edges-array.json @@ -0,0 +1,4 @@ +{ + "nodes": [], + "edges": {} +} diff --git a/Tests/JSONInspectionTests/GraphFixtures/generate_graph_fixtures.py b/Tests/JSONInspectionTests/GraphFixtures/generate_graph_fixtures.py new file mode 100755 index 0000000..e95e9d2 --- /dev/null +++ b/Tests/JSONInspectionTests/GraphFixtures/generate_graph_fixtures.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Independent design-fixture generator. NOT a Swift-JSON semantic or runtime qualification.""" +from pathlib import Path +import json, hashlib +base=Path(__file__).resolve().parent +cases={ + 'g01-empty':({'nodes':[],'edges':[]},{},'valid'), + 'g02-simple':({'nodes':[{'id':'A'},{'id':'B'},{'id':'C'}], 'edges':[{'from':'A','to':'B'},{'from':'A','to':'C'},{'from':'B','to':'C'}]}, {},'valid'), + 'g03-unicode-scalars':({'nodes':[{'id':'é'},{'id':'e\u0301'}],'edges':[{'from':'é','to':'e\u0301'}]}, {},'valid'), + 'g04-empty-and-special-ids':({'nodes':[{'id':''},{'id':'a/b~c'},{'id':'0'}], 'edges':[{'from':'','to':'a/b~c'},{'from':'0','to':''}]},{},'valid'), + 'g05-duplicate-id':({'nodes':[{'id':'A','name':'x'},{'id':'A','name':'y'}],'edges':[]},{},'duplicate_node_id'), + 'g06-equal-payload-distinct-id':({'nodes':[{'id':'A','same':{'x':1}},{'id':'B','same':{'x':1}}],'edges':[]},{},'valid'), + 'g07-node-id-missing':({'nodes':[{'name':'A'}],'edges':[]},{},'node_id_missing'), + 'g08-node-id-null':({'nodes':[{'id':None}],'edges':[]},{},'node_id_null'), + 'g09-node-id-number':({'nodes':[{'id':22}],'edges':[]},{},'node_id_wrong_kind'), + 'g10-node-row-not-object':({'nodes':['A'],'edges':[]},{},'node_row_wrong_kind'), + 'g11-edge-source-missing':({'nodes':[{'id':'A'}],'edges':[{'to':'A'}]},{},'edge_source_missing'), + 'g12-edge-target-null':({'nodes':[{'id':'A'}],'edges':[{'from':'A','to':None}]},{},'edge_target_null'), + 'g13-edge-target-number':({'nodes':[{'id':'A'}],'edges':[{'from':'A','to':3}]},{},'edge_target_wrong_kind'), + 'g14-edge-row-not-object':({'nodes':[{'id':'A'}],'edges':[3]},{},'edge_row_wrong_kind'), + 'g15-dangling':({'nodes':[{'id':'A'}],'edges':[{'from':'A','to':'B'}]},{},'dangling_endpoint'), + 'g15b-dangling-empty':({'nodes':[],'edges':[{'from':'A','to':'A'}]},{},'dangling_endpoint'), + 'g16-duplicate-edge':({'nodes':[{'id':'A'},{'id':'B'}],'edges':[{'from':'A','to':'B'},{'from':'A','to':'B'}]},{},'duplicate_directed_edge'), + 'g17-reverse-edges':({'nodes':[{'id':'A'},{'id':'B'}],'edges':[{'from':'A','to':'B'},{'from':'B','to':'A'}]},{},'valid'), + 'g18-self-edge':({'nodes':[{'id':'A'}],'edges':[{'from':'A','to':'A'}]},{},'valid'), + 'g19-cycle':({'nodes':[{'id':'A'},{'id':'B'},{'id':'C'}],'edges':[{'from':'A','to':'B'},{'from':'B','to':'C'},{'from':'C','to':'A'}]},{},'valid'), + 'g20-isolate':({'nodes':[{'id':'A'},{'id':'B'},{'id':'ISO'}],'edges':[{'from':'A','to':'B'}]},{},'valid'), + 'g21-bfs-source-edge-order':({'nodes':[{'id':x} for x in ['A','B','C','D']],'edges':[{'from':'A','to':'D'},{'from':'A','to':'B'},{'from':'A','to':'C'}]},{},'valid'), + 'g22-diamond':({'nodes':[{'id':x} for x in ['A','B','C','D']],'edges':[{'from':'A','to':'B'},{'from':'A','to':'C'},{'from':'B','to':'D'},{'from':'C','to':'D'}]},{},'valid'), + 'g23-numeric-object-key':({'0':{'nodes':[{'key/id':'0'},{'key/id':'A'}],'edges':[{'src~':'0','dst':'A'}]}},{'nodes_at':[{'key':'0'},{'key':'nodes'}],'edges_at':[{'key':'0'},{'key':'edges'}],'node_id_key':'key/id','edge_source_key':'src~','edge_target_key':'dst'},'valid'), + 'g24-overlap':({'data':[{'id':'A'}]},{'nodes_at':[{'key':'data'}],'edges_at':[{'key':'data'}]},'overlap'), + 'g25-wrong-nodes-array':({'nodes':{},'edges':[]},{},'nodes_not_array'), + 'g26-wrong-edges-array':({'nodes':[],'edges':{}},{},'edges_not_array'), +} +def tok_path(root,segs): + val=root + for seg in segs: + if 'key' in seg: + if not isinstance(val,dict):raise TypeError('path_expected_object') + val=val[seg['key']] + else: + if not isinstance(val,list):raise TypeError('path_expected_array') + val=val[seg['index']] + return val +def reference(obj, prof): + np=prof.get('nodes_at',[{'key':'nodes'}]);ep=prof.get('edges_at',[{'key':'edges'}]);node=prof.get('node_id_key','id'); fr=prof.get('edge_source_key','from');to=prof.get('edge_target_key','to') + if np==ep or (len(np)<=len(ep) and np==ep[:len(np)]) or (len(ep)<=len(np) and ep==np[:len(ep)]):return {'kind':'overlap'} + try:nodes=tok_path(obj,np);edges=tok_path(obj,ep) + except Exception:return {'kind':'missing_or_invalid_selection'} + if not isinstance(nodes,list):return {'kind':'nodes_not_array'} + if not isinstance(edges,list):return {'kind':'edges_not_array'} + ids=[] + for i,n in enumerate(nodes): + if not isinstance(n,dict):return {'kind':'node_row_wrong_kind','node_index':i} + if node not in n:return {'kind':'node_id_missing','node_index':i} + if n[node] is None:return {'kind':'node_id_null','node_index':i} + if not isinstance(n[node],str):return {'kind':'node_id_wrong_kind','node_index':i} + if n[node] in ids:return {'kind':'duplicate_node_id','node_index':i,'first_index':ids.index(n[node])} + ids.append(n[node]) + pairs=[];outgoing=[[] for _ in nodes] + for eindex,e in enumerate(edges): + if not isinstance(e,dict):return {'kind':'edge_row_wrong_kind','edge_index':eindex} + for field,field_name in ((fr,'edge_source'),(to,'edge_target')): + if field not in e:return {'kind':field_name+'_missing','edge_index':eindex} + if e[field] is None:return {'kind':field_name+'_null','edge_index':eindex} + if not isinstance(e[field],str):return {'kind':field_name+'_wrong_kind','edge_index':eindex} + if e[fr] not in ids or e[to] not in ids:return {'kind':'dangling_endpoint','edge_index':eindex} + pair=(ids.index(e[fr]),ids.index(e[to])) + if pair in pairs:return {'kind':'duplicate_directed_edge','edge_index':eindex,'first_index':pairs.index(pair)} + pairs.append(pair);outgoing[pair[0]].append(eindex) + visited=[False]*len(ids);order=[];queue=[] + if nodes: + queue=[0];visited[0]=True;head=0 + while head