diff --git a/bench/__main__.py b/bench/__main__.py index 7ccc9aad..3c2fcd42 100644 --- a/bench/__main__.py +++ b/bench/__main__.py @@ -127,6 +127,7 @@ def _at(base: int, scale: float) -> int: Bench('source-structure', lambda root, scale: corpus.write_hover(root, family_count=_at(400, scale))), Bench('service-session', lambda root, scale: corpus.write_hover(root, family_count=_at(400, scale))), Bench('service-source-first', lambda root, scale: corpus.write_hover(root, family_count=_at(400, scale))), + Bench('service-navigation', lambda root, scale: corpus.write_hover(root, family_count=_at(400, scale))), ) _BY_NAME = {bench.name: bench for bench in BENCHES} @@ -165,6 +166,7 @@ def run_one(bench: str, config: str, entry: Path, repeat: int) -> Measurement: 'source-structure', 'service-session', 'service-source-first', + 'service-navigation', }: return _measure_view(bench, entry, repeat) if config == 'service': @@ -211,6 +213,7 @@ def _measure_view(bench: str, entry: Path, repeat: int) -> Measurement: 'source-structure': _measure_source_structure, 'service-session': _measure_service_session, 'service-source-first': lambda entry, repeat: _measure_service_session(entry, repeat, source_first=True), + 'service-navigation': _measure_service_navigation, }.get(bench) if service_workload is not None: return service_workload(entry, repeat) @@ -414,6 +417,15 @@ def _measure_service_session(entry: Path, repeat: int, *, source_first: bool = F return measure(name, 'unwrap', lambda: exercise(prepared, source_first=source_first), repeat=repeat) +def _measure_service_navigation(entry: Path, repeat: int) -> Measurement: + from bench.service_navigation import exercise, prepare # noqa: PLC0415 - feature workload only + from fastraml.gctuning import tuned_gc # noqa: PLC0415 - feature workload only + + prepared = prepare(entry) + with tuned_gc(): + return measure('service-navigation', 'unwrap', lambda: exercise(prepared), repeat=repeat) + + def _measure_edit(bench: str, entry: Path, repeat: int) -> Measurement: """One edit to the root's buffer, and what the editor then asks for first.""" from itertools import count # noqa: PLC0415 - as above @@ -585,6 +597,7 @@ def compare(results: Sequence[Measurement], tolerance: float) -> int: 'source-structure': 'unwrap', 'service-session': 'unwrap', 'service-source-first': 'unwrap', + 'service-navigation': 'unwrap', } diff --git a/bench/service_navigation.py b/bench/service_navigation.py new file mode 100644 index 00000000..15a10aa3 --- /dev/null +++ b/bench/service_navigation.py @@ -0,0 +1,85 @@ +"""Sparse semantic requests across three edits, not an observed client trace.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from bench.service_session import SessionInput +from bench.service_session import prepare as prepare_session +from fastraml.service import outline, queries +from fastraml.service.workspace import Workspace + +if TYPE_CHECKING: + from pathlib import Path + + +@dataclass(frozen=True, slots=True, eq=False) +class NavigationInput: + session: SessionInput + types: tuple[tuple[int, int], ...] + data: tuple[tuple[int, int], ...] + + +def prepare(entry: Path) -> NavigationInput: + session = prepare_session(entry) + types = [] + data = [] + for line, raw in enumerate(session.focus_text.splitlines(), 1): + if raw.startswith((' Word', ' Name')): + types.append((line, 3)) + elif raw.strip() == 'note: Facet note.': + data.append((line, len(raw) - len(raw.lstrip()) + 1)) + pair_size = 2 + if len(types) < pair_size or not data: + raise RuntimeError('navigation corpus lost its sparse type/data sites') + # Three family pairs and three data sites, independent of input width. + families = len(types) // pair_size + chosen = (0, families // 2, families - 1) + return NavigationInput( + session, + tuple(types[pair_size * family + member] for family in chosen for member in range(pair_size)), + tuple(data[family] for family in chosen), + ) + + +def exercise(prepared: NavigationInput) -> tuple[Workspace, dict[str, int]]: + session = prepared.session + workspace = Workspace([session.folder]) + counts: dict[str, int] = dict.fromkeys( + ('snapshots', 'types', 'supers', 'subs', 'definitions', 'outlines', 'symbols'), 0 + ) + for version, text in enumerate(session.versions, 1): + workspace.change(session.root, text, version) + workspace.collect() + snapshot = workspace.snapshot(session.root) + if snapshot.error is not None or snapshot.raml is None: + raise RuntimeError('navigation corpus lost its valid snapshot') + counts['snapshots'] += 1 + queries.diagnostics(snapshot, lint=False) + snapshot.occurrences # noqa: B018 - part of diagnostic/navigation readiness + for line, column in prepared.types: + item = queries.type_at(snapshot, session.root, line, column) + if item is None: + raise RuntimeError('navigation corpus lost a type preparation') + counts['types'] += 1 + parents = queries.supertypes(snapshot, item) + children = queries.subtypes(snapshot, item) + if (item.name.startswith('Name') and not parents) or (item.name.startswith('Word') and not children): + raise RuntimeError('navigation corpus lost an inheritance edge') + counts['supers'] += 1 + counts['subs'] += 1 + for line, column in prepared.data: + if not queries.definition(snapshot, session.root, line, column): + raise RuntimeError('navigation corpus lost a typed-data definition') + counts['definitions'] += 1 + for _ in range(2): + if not outline.document_symbols(snapshot, session.root): + raise RuntimeError('navigation corpus lost its outline') + counts['outlines'] += 1 + for wanted in ('Word', 'MetadataLeaf'): + if not queries.workspace_symbols([snapshot], wanted): + raise RuntimeError('navigation corpus lost its workspace symbols') + counts['symbols'] += 1 + del snapshot + return workspace, counts diff --git a/docs/12-performance.md b/docs/12-performance.md index bc39c74a..5de4ab03 100644 --- a/docs/12-performance.md +++ b/docs/12-performance.md @@ -86,7 +86,7 @@ must receive a parser diagnostic rather than `RecursionError`. ## 4. Benchmark suite -`bench/` generates deterministic corpora and measures thirty-three workloads: +`bench/` generates deterministic corpora and measures thirty-four workloads: | Bench | Primary coverage | |---|---| @@ -122,11 +122,12 @@ must receive a parser diagnostic rather than `RecursionError`. | `inlays` | a cold snapshot, inferred declaration types, expected types at supplied custom-facet/annotation roots and nested data keys, and compact inherited constraints anchored to type references ([21](21-language-service.md) § 4.4) | | `source-structure` | three folding requests and twelve selection requests at scattered keys, served from the workspace's composed source tree, composed once per current text ([21](21-language-service.md) § 4) | | `service-session`, `service-source-first` | representative mixed editor requests over three root-buffer versions: parser diagnostics, occurrences, outline, links, lens enumeration, 120-line viewport inlays, folding, three sparse hovers and warm queries; source-first additionally requests folding before each snapshot | +| `service-navigation` | three root-buffer versions, each with diagnostics/occurrences, six fixed sparse hierarchy preparations and parent/child queries, three typed-data definitions before hover, two outlines and two workspace-symbol searches | The six general workloads are `small`, `large`, `endpoints`, `extensions`, -`validate` and `jsonschema`. The other twenty-seven are feature workloads: +`validate` and `jsonschema`. The other twenty-eight are feature workloads: each exists because no general workload runs the code it covers. Their reach -tests (`tests/bench/test_corpus.py`) count calls or check bound results, and fail +tests under `tests/bench/` count calls or check bound results, and fail if a corpus stops reaching that code at every size it covers. A feature added to the language has no baseline on `master`, where the corpus @@ -163,6 +164,10 @@ probe selection and corpus generation are outside the measured region. For `service-session` and `service-source-first`, `unwrap` runs the three-version mixed request sequences of § 4.2; their other configurations keep their ordinary parse/view meanings. Input texts and sparse probes are prepared outside measurement. +For `service-navigation`, `unwrap` runs the sparse three-version navigation +sequence. Requests are fixed as file width grows; only the latest workspace/model +and caches remain live, not request answers. Generation and probe selection are +outside measurement; there is no lint, source-only request or protocol conversion. ```bash python -m bench run diff --git a/docs/15-implementation-plan.md b/docs/15-implementation-plan.md index cfddd0ca..308be23f 100644 --- a/docs/15-implementation-plan.md +++ b/docs/15-implementation-plan.md @@ -26,9 +26,14 @@ all CI checks passed and the baseline is merged. The first isolated recovery experiment implements model-backed declaration inlays against that accepted control. Its correctness, scaling and A/B results are recorded in the [inlay recovery report](reports/2026-10-10/service-model-inlays.md), including its -accepted snapshot-first mixed allocation-peak trade-off. Remaining recovery -candidates are shared semantic/hierarchy indices and outline caching, cursor-local -source lookup, and accurate authored section ranges, each measured independently. +accepted snapshot-first mixed allocation-peak trade-off. +The shared-index/outline bundle is the next isolated recovery against merged +`3912118`. Correctness, reach and scaling pass; sparse navigation is 17.0% faster, +but mixed retained allocation rises 7.5%, chiefly the cached outline. An explicit +acceptance of that new retention trade-off is recorded; the scope, measurements and +ownership evidence are in the [shared-index report](reports/2026-10-10/service-shared-indices.md). +Remaining candidates are cursor-local source lookup and accurate authored section +ranges, each measured independently. The record-backed representation replacement remains parked; its recurring rebuild and first-use costs are recorded in the [service cost review](reports/2026-10-10/service-authoring-cost-review.md). diff --git a/docs/21-language-service.md b/docs/21-language-service.md index aaf4c4bb..33a44cf3 100644 --- a/docs/21-language-service.md +++ b/docs/21-language-service.md @@ -27,6 +27,7 @@ and several views, and only `cli/` imports it (`docs/02` § 2; | `service/outline.py` | the outline, over the authorship view (`docs/16` § 10) | | `service/hover.py`, `service/hoverdocs.py` | author-facing hover, source-key indices and explanatory prose (§ 4.2) | | `service/datahover.py` | typed `DataNode` key and value spans, shared across value-bearing sites (§ 4.2) | +| `service/index.py` | lazy declaration, ID and reverse-hierarchy lookups shared by snapshot queries (§ 4) | | `service/lenses.py` | code-lens sites and on-demand effective RAML type rendering (§ 4.3) | | `service/inlays.py` | inline type/facet labels over the shared authoring indices (§ 4.4) | | `service/lsp.py` | the LSP adapter, over pygls (§ 5) | @@ -162,6 +163,17 @@ stages it completed. A declaration is an occurrence after decoding; a type name used in an expression is one only once P7 bound it. No query raises on a snapshot that stopped at any stage (`test_service_queries.py`). +**Shared semantic indices.** A snapshot owns lazy declaration enumeration and +file grouping, shared by outline, workspace symbols and hover subjects. ID lookup +populates separately on first hierarchy preparation/rebinding; the reverse map +populates separately on first subtype request. It records direct `inherits` and +`alias` edges, deduplicates repeated parents, and preserves child declaration order. +Multiple-inheritance scalar wrappers follow their bound alias to the named +declaration, so hierarchy locations select the declaration rather than the parent +list token. A declared alias itself remains a named hierarchy item. +Diagnostics and occurrences do not populate these caches. Each root snapshot owns +its semantic context; an edit replaces the caches even for unchanged dependencies. + **Outline.** Every entry is read from the model, and grouped as the file groups it, the way a code outline reads: `title`, `version` and `baseUri` with their values, then one section per declaration table (`uses`, `types`, @@ -181,6 +193,12 @@ model; `JSON schema` for a `JsonShape`; or where nothing was written, is its kind: object, array, union, enum or a scalar's. A resource's, method's or response's detail is its `displayName`, a response's else its description. +The first outline request for a URI caches its complete result on the snapshot, +including an empty outline. Later requests borrow the same list and symbols; +callers must treat them as read-only. Root/dependency edits create a new snapshot +and outline cache. A held older snapshot continues to answer from its older model. +Caching does not add authored section positions or populate source grammar. + The model keeps no position for a section's key (`types:`, a method's `headers:`), so a section spans its entries and selects the first. @@ -316,9 +334,11 @@ parser diagnostics. Hover indices and formatted subjects are lazy per snapshot. Source keys are indexed once per queried file from retained nodes, or a composition of its retained text when source trees were not retained. These nodes and indices die with the snapshot. -The typed-data token index is also lazy and built once per snapshot; formatted -data targets are cached. The same typed-data targets supply go-to-definition -for nested field keys and scalar values. +The typed-data token index is owned by the snapshot, lazy and built once; formatted +data targets are cached by hover. The same typed-data targets supply go-to-definition +for nested field keys and scalar values. A definition request may populate that +index without creating hover subjects, formatting hover or composing source. A +later hover/inlay request uses the same token index, not a second traversal. ### 4.3 Effective-type code lenses diff --git a/docs/archive/briefs/phase-9.md b/docs/archive/briefs/phase-9.md index ceac3e34..01fbb24c 100644 --- a/docs/archive/briefs/phase-9.md +++ b/docs/archive/briefs/phase-9.md @@ -133,7 +133,7 @@ argued for in Phase 2 and may have been overtaken. 3. **`uniqueItems` switches strategy at 20 items** (`docs/10` § 5.2). Measured against go-raml, not guessed. 4. **The union-facet gap stays open.** It is After-v1 item 2 in `docs/15`, and - the fix has to land in go-raml too — the user's call, already made. + the fix has to land in go-raml too. --- diff --git a/docs/reports/2026-10-10/service-shared-indices-phase-metrics.json b/docs/reports/2026-10-10/service-shared-indices-phase-metrics.json new file mode 100644 index 00000000..2c4a13d3 --- /dev/null +++ b/docs/reports/2026-10-10/service-shared-indices-phase-metrics.json @@ -0,0 +1,690 @@ +{ + "corpus": "hover", + "scale": 1, + "inputs": { + "files": 1, + "bytes": 311308, + "entry_bytes": 311308, + "focus_bytes": 311308 + }, + "detail_timers": false, + "mixed": false, + "source_first": false, + "platform": "Windows-10-10.0.19045-SP0", + "trees": [ + [ + "baseline", + "../service-model-inlays" + ], + [ + "shared-indices", + "." + ] + ], + "results": { + "baseline": [ + { + "phases": [ + { + "change": 0.0004630996845662594, + "collect": 0.01575649995356798, + "snapshot": 0.22033239994198084, + "diagnostics": 2.400018274784088e-06, + "occurrences": 0.030596300028264523, + "total": 0.2671506996266544 + }, + { + "change": 0.0005130995996296406, + "collect": 0.017384700011461973, + "snapshot": 0.20974249998107553, + "diagnostics": 2.100132405757904e-06, + "occurrences": 0.019583600107580423, + "total": 0.24722599983215332 + }, + { + "change": 0.0004405002109706402, + "collect": 0.014750299975275993, + "snapshot": 0.19708860013633966, + "diagnostics": 1.0998919606208801e-06, + "occurrences": 0.019704800099134445, + "total": 0.23198530031368136 + } + ], + "queries": [ + { + "outline.first": 0.06496060034260154, + "lenses.first": 0.003166200127452612, + "inlays.first": 0.1289695999585092, + "folding.first": 0.20554970018565655, + "hover.first_after_auto": 0.08293130015954375, + "outline.warm": 0.06011429987847805, + "inlays.warm": 0.0004905001260340214, + "folding.warm": 0.02865200024098158, + "hover.warm": 3.1599774956703186e-05 + }, + { + "outline.first": 0.10216260002925992, + "lenses.first": 0.0034560002386569977, + "inlays.first": 0.1286717001348734, + "folding.first": 0.17921569989994168, + "hover.first_after_auto": 0.094749900046736, + "outline.warm": 0.08638059999793768, + "inlays.warm": 0.00020699994638562202, + "folding.warm": 0.027434200048446655, + "hover.warm": 3.100000321865082e-05 + }, + { + "outline.first": 0.03165020002052188, + "lenses.first": 0.0015224004164338112, + "inlays.first": 0.09611269971355796, + "folding.first": 0.14823799999430776, + "hover.first_after_auto": 0.03834020020440221, + "outline.warm": 0.03448190027847886, + "inlays.warm": 0.00011290004476904869, + "folding.warm": 0.013348099775612354, + "hover.warm": 2.459995448589325e-05 + } + ], + "memory": { + "snapshot": { + "kept": 9695174, + "peak": 16383969 + }, + "diagnostics_occurrences": { + "kept": 12052220, + "peak": 16383969 + }, + "automatic_queries": { + "kept": 23245922, + "peak": 33684035 + }, + "sparse_hovers": { + "kept": 25872455, + "peak": 33684035 + } + }, + "dimensions": { + "read_files": 1, + "shapes": 7200, + "endpoints": 400, + "captured_files": 0, + "retained_source_records": 0 + }, + "counts": { + "v1.snapshot.yaml_composes": 1, + "input.b0de2b90a79d": 2, + "v1.snapshot.converted_nodes": 32007, + "v1.automatic.yaml_composes": 1, + "v1.automatic.converted_nodes": 32007, + "v2.snapshot.yaml_composes": 1, + "input.7cd2b060b6fd": 2, + "v2.snapshot.converted_nodes": 32007, + "v2.automatic.yaml_composes": 1, + "v2.automatic.converted_nodes": 32007, + "v3.snapshot.yaml_composes": 1, + "input.b2c9a97bfc7a": 2, + "v3.snapshot.converted_nodes": 32007, + "v3.automatic.yaml_composes": 1, + "v3.automatic.converted_nodes": 32007, + "broken.snapshot_composes": 1, + "broken.three_source_request_composes": 1 + }, + "python": "3.12.13 (main, May 10 2026, 19:35:37) [MSC v.1944 64 bit (AMD64)]", + "backend": "libyaml", + "import": "fastraml/__init__.py" + }, + { + "phases": [ + { + "change": 0.0007929001003503799, + "collect": 0.024710299912840128, + "snapshot": 0.308942100033164, + "diagnostics": 2.6999041438102722e-06, + "occurrences": 0.034205700270831585, + "total": 0.3686537002213299 + }, + { + "change": 0.0008319998160004616, + "collect": 0.02458340022712946, + "snapshot": 0.3165822997689247, + "diagnostics": 2.500135451555252e-06, + "occurrences": 0.037099599838256836, + "total": 0.379099799785763 + }, + { + "change": 0.0008169999346137047, + "collect": 0.01842449977993965, + "snapshot": 0.2946385000832379, + "diagnostics": 1.700129359960556e-06, + "occurrences": 0.027463600039482117, + "total": 0.3413452999666333 + } + ], + "queries": [ + { + "outline.first": 0.06321900011971593, + "lenses.first": 0.001880900003015995, + "inlays.first": 0.10451279999688268, + "folding.first": 0.1133640999905765, + "hover.first_after_auto": 0.07373319985345006, + "outline.warm": 0.06437759986147285, + "inlays.warm": 0.0002758000046014786, + "folding.warm": 0.02325990004464984, + "hover.warm": 2.9999762773513794e-05 + }, + { + "outline.first": 0.06520939990878105, + "lenses.first": 0.0029898998327553272, + "inlays.first": 0.1281631998717785, + "folding.first": 0.10736289992928505, + "hover.first_after_auto": 0.056280000135302544, + "outline.warm": 0.04117690026760101, + "inlays.warm": 0.00011620018631219864, + "folding.warm": 0.014140500221401453, + "hover.warm": 2.020038664340973e-05 + }, + { + "outline.first": 0.040577900130301714, + "lenses.first": 0.001590899657458067, + "inlays.first": 0.060815099626779556, + "folding.first": 0.08868699986487627, + "hover.first_after_auto": 0.06796550005674362, + "outline.warm": 0.04758859984576702, + "inlays.warm": 0.0001805000938475132, + "folding.warm": 0.015046800021082163, + "hover.warm": 3.6499928683042526e-05 + } + ], + "memory": { + "snapshot": { + "kept": 9683319, + "peak": 16383910 + }, + "diagnostics_occurrences": { + "kept": 12040553, + "peak": 16383910 + }, + "automatic_queries": { + "kept": 23235171, + "peak": 33673237 + }, + "sparse_hovers": { + "kept": 25861845, + "peak": 33673237 + } + }, + "dimensions": { + "read_files": 1, + "shapes": 7200, + "endpoints": 400, + "captured_files": 0, + "retained_source_records": 0 + }, + "counts": { + "v1.snapshot.yaml_composes": 1, + "input.b0de2b90a79d": 2, + "v1.snapshot.converted_nodes": 32007, + "v1.automatic.yaml_composes": 1, + "v1.automatic.converted_nodes": 32007, + "v2.snapshot.yaml_composes": 1, + "input.7cd2b060b6fd": 2, + "v2.snapshot.converted_nodes": 32007, + "v2.automatic.yaml_composes": 1, + "v2.automatic.converted_nodes": 32007, + "v3.snapshot.yaml_composes": 1, + "input.b2c9a97bfc7a": 2, + "v3.snapshot.converted_nodes": 32007, + "v3.automatic.yaml_composes": 1, + "v3.automatic.converted_nodes": 32007, + "broken.snapshot_composes": 1, + "broken.three_source_request_composes": 1 + }, + "python": "3.12.13 (main, May 10 2026, 19:35:37) [MSC v.1944 64 bit (AMD64)]", + "backend": "libyaml", + "import": "fastraml/__init__.py" + }, + { + "phases": [ + { + "change": 0.0003610001876950264, + "collect": 0.013691300060600042, + "snapshot": 0.17767099989578128, + "diagnostics": 1.700129359960556e-06, + "occurrences": 0.02747729979455471, + "total": 0.21920230006799102 + }, + { + "change": 0.0006649000570178032, + "collect": 0.015065600164234638, + "snapshot": 0.17958239978179336, + "diagnostics": 1.8998980522155762e-06, + "occurrences": 0.0177776999771595, + "total": 0.2130924998782575 + }, + { + "change": 0.0004267999902367592, + "collect": 0.013548600021749735, + "snapshot": 0.24350970005616546, + "diagnostics": 1.4998950064182281e-06, + "occurrences": 0.022743599954992533, + "total": 0.2802301999181509 + } + ], + "queries": [ + { + "outline.first": 0.038329599890857935, + "lenses.first": 0.0017123003490269184, + "inlays.first": 0.0562992999330163, + "folding.first": 0.10608559986576438, + "hover.first_after_auto": 0.03974610008299351, + "outline.warm": 0.04345300002023578, + "inlays.warm": 0.00011219969019293785, + "folding.warm": 0.014161000028252602, + "hover.warm": 2.369983121752739e-05 + }, + { + "outline.first": 0.03610090026631951, + "lenses.first": 0.0016353996470570564, + "inlays.first": 0.0580163998529315, + "folding.first": 0.09167859982699156, + "hover.first_after_auto": 0.035135400015860796, + "outline.warm": 0.034023699816316366, + "inlays.warm": 0.00012060021981596947, + "folding.warm": 0.015348299872130156, + "hover.warm": 2.440018579363823e-05 + }, + { + "outline.first": 0.05849859956651926, + "lenses.first": 0.0031691002659499645, + "inlays.first": 0.0735279000364244, + "folding.first": 0.10275710001587868, + "hover.first_after_auto": 0.06110599962994456, + "outline.warm": 0.03860619990155101, + "inlays.warm": 0.00018670037388801575, + "folding.warm": 0.016767899971455336, + "hover.warm": 1.9399914890527725e-05 + } + ], + "memory": { + "snapshot": { + "kept": 9693799, + "peak": 16383910 + }, + "diagnostics_occurrences": { + "kept": 12051080, + "peak": 16383910 + }, + "automatic_queries": { + "kept": 23244406, + "peak": 33682472 + }, + "sparse_hovers": { + "kept": 25870880, + "peak": 33682472 + } + }, + "dimensions": { + "read_files": 1, + "shapes": 7200, + "endpoints": 400, + "captured_files": 0, + "retained_source_records": 0 + }, + "counts": { + "v1.snapshot.yaml_composes": 1, + "input.b0de2b90a79d": 2, + "v1.snapshot.converted_nodes": 32007, + "v1.automatic.yaml_composes": 1, + "v1.automatic.converted_nodes": 32007, + "v2.snapshot.yaml_composes": 1, + "input.7cd2b060b6fd": 2, + "v2.snapshot.converted_nodes": 32007, + "v2.automatic.yaml_composes": 1, + "v2.automatic.converted_nodes": 32007, + "v3.snapshot.yaml_composes": 1, + "input.b2c9a97bfc7a": 2, + "v3.snapshot.converted_nodes": 32007, + "v3.automatic.yaml_composes": 1, + "v3.automatic.converted_nodes": 32007, + "broken.snapshot_composes": 1, + "broken.three_source_request_composes": 1 + }, + "python": "3.12.13 (main, May 10 2026, 19:35:37) [MSC v.1944 64 bit (AMD64)]", + "backend": "libyaml", + "import": "fastraml/__init__.py" + } + ], + "shared-indices": [ + { + "phases": [ + { + "change": 0.0008437000215053558, + "collect": 0.03398949978873134, + "snapshot": 0.30103160021826625, + "diagnostics": 1.600012183189392e-06, + "occurrences": 0.024419599678367376, + "total": 0.3602859997190535 + }, + { + "change": 0.0006979000754654408, + "collect": 0.02754189958795905, + "snapshot": 0.4125548000447452, + "diagnostics": 2.800021320581436e-06, + "occurrences": 0.041292000096291304, + "total": 0.4820893998257816 + }, + { + "change": 0.0007535000331699848, + "collect": 0.02691530017182231, + "snapshot": 0.5158669999800622, + "diagnostics": 1.600012183189392e-06, + "occurrences": 0.04282799968495965, + "total": 0.5863653998821974 + } + ], + "queries": [ + { + "outline.first": 0.10508350003510714, + "lenses.first": 0.005485299974679947, + "inlays.first": 0.15297550009563565, + "folding.first": 0.2056954000145197, + "hover.first_after_auto": 0.11082349997013807, + "outline.warm": 2.800021320581436e-06, + "inlays.warm": 0.00021950015798211098, + "folding.warm": 0.03863559989258647, + "hover.warm": 4.719989374279976e-05 + }, + { + "outline.first": 0.032580200117081404, + "lenses.first": 0.001964000053703785, + "inlays.first": 0.15468090027570724, + "folding.first": 0.2687767003662884, + "hover.first_after_auto": 0.06743300007656217, + "outline.warm": 1.8998980522155762e-06, + "inlays.warm": 0.00012869993224740028, + "folding.warm": 0.01604540040716529, + "hover.warm": 2.640020102262497e-05 + }, + { + "outline.first": 0.07263710023835301, + "lenses.first": 0.0069193001836538315, + "inlays.first": 0.18110249983146787, + "folding.first": 0.2847458999603987, + "hover.first_after_auto": 0.09974410012364388, + "outline.warm": 1.700129359960556e-06, + "inlays.warm": 0.0001868000254034996, + "folding.warm": 0.025000799912959337, + "hover.warm": 2.9399991035461426e-05 + } + ], + "memory": { + "snapshot": { + "kept": 9683427, + "peak": 16383918 + }, + "diagnostics_occurrences": { + "kept": 12040661, + "peak": 16383918 + }, + "automatic_queries": { + "kept": 25169143, + "peak": 35607209 + }, + "sparse_hovers": { + "kept": 27795864, + "peak": 35607209 + } + }, + "dimensions": { + "read_files": 1, + "shapes": 7200, + "endpoints": 400, + "captured_files": 0, + "retained_source_records": 0 + }, + "counts": { + "v1.snapshot.yaml_composes": 1, + "input.b0de2b90a79d": 2, + "v1.snapshot.converted_nodes": 32007, + "v1.automatic.yaml_composes": 1, + "v1.automatic.converted_nodes": 32007, + "v2.snapshot.yaml_composes": 1, + "input.7cd2b060b6fd": 2, + "v2.snapshot.converted_nodes": 32007, + "v2.automatic.yaml_composes": 1, + "v2.automatic.converted_nodes": 32007, + "v3.snapshot.yaml_composes": 1, + "input.b2c9a97bfc7a": 2, + "v3.snapshot.converted_nodes": 32007, + "v3.automatic.yaml_composes": 1, + "v3.automatic.converted_nodes": 32007, + "broken.snapshot_composes": 1, + "broken.three_source_request_composes": 1 + }, + "python": "3.12.13 (main, May 10 2026, 19:35:37) [MSC v.1944 64 bit (AMD64)]", + "backend": "libyaml", + "import": "fastraml/__init__.py" + }, + { + "phases": [ + { + "change": 0.0005135000683367252, + "collect": 0.019818800035864115, + "snapshot": 0.2522801999002695, + "diagnostics": 2.00001522898674e-06, + "occurrences": 0.02124470006674528, + "total": 0.2938592000864446 + }, + { + "change": 0.00043139979243278503, + "collect": 0.015360299963504076, + "snapshot": 0.19593280041590333, + "diagnostics": 2.00001522898674e-06, + "occurrences": 0.02161179995164275, + "total": 0.23333830013871193 + }, + { + "change": 0.0006221998482942581, + "collect": 0.020528200082480907, + "snapshot": 0.2504028999246657, + "diagnostics": 1.3997778296470642e-06, + "occurrences": 0.02436240017414093, + "total": 0.29591709980741143 + } + ], + "queries": [ + { + "outline.first": 0.04590719984844327, + "lenses.first": 0.0033332998864352703, + "inlays.first": 0.08055320009589195, + "folding.first": 0.10344660002738237, + "hover.first_after_auto": 0.04165260028094053, + "outline.warm": 1.7997808754444122e-06, + "inlays.warm": 0.0001183999702334404, + "folding.warm": 0.013324299827218056, + "hover.warm": 1.9200146198272705e-05 + }, + { + "outline.first": 0.034541800152510405, + "lenses.first": 0.002007800154387951, + "inlays.first": 0.08386800019070506, + "folding.first": 0.08234670013189316, + "hover.first_after_auto": 0.03608620027080178, + "outline.warm": 1.600012183189392e-06, + "inlays.warm": 0.00011810008436441422, + "folding.warm": 0.012513000052422285, + "hover.warm": 1.8800143152475357e-05 + }, + { + "outline.first": 0.05059490026906133, + "lenses.first": 0.0020582997240126133, + "inlays.first": 0.20981110027059913, + "folding.first": 0.21491090022027493, + "hover.first_after_auto": 0.06381299998611212, + "outline.warm": 1.300126314163208e-06, + "inlays.warm": 0.00015719980001449585, + "folding.warm": 0.021993900183588266, + "hover.warm": 2.7099624276161194e-05 + } + ], + "memory": { + "snapshot": { + "kept": 9684978, + "peak": 16383977 + }, + "diagnostics_occurrences": { + "kept": 12042212, + "peak": 16383977 + }, + "automatic_queries": { + "kept": 25170388, + "peak": 35608513 + }, + "sparse_hovers": { + "kept": 27797109, + "peak": 35608513 + } + }, + "dimensions": { + "read_files": 1, + "shapes": 7200, + "endpoints": 400, + "captured_files": 0, + "retained_source_records": 0 + }, + "counts": { + "v1.snapshot.yaml_composes": 1, + "input.b0de2b90a79d": 2, + "v1.snapshot.converted_nodes": 32007, + "v1.automatic.yaml_composes": 1, + "v1.automatic.converted_nodes": 32007, + "v2.snapshot.yaml_composes": 1, + "input.7cd2b060b6fd": 2, + "v2.snapshot.converted_nodes": 32007, + "v2.automatic.yaml_composes": 1, + "v2.automatic.converted_nodes": 32007, + "v3.snapshot.yaml_composes": 1, + "input.b2c9a97bfc7a": 2, + "v3.snapshot.converted_nodes": 32007, + "v3.automatic.yaml_composes": 1, + "v3.automatic.converted_nodes": 32007, + "broken.snapshot_composes": 1, + "broken.three_source_request_composes": 1 + }, + "python": "3.12.13 (main, May 10 2026, 19:35:37) [MSC v.1944 64 bit (AMD64)]", + "backend": "libyaml", + "import": "fastraml/__init__.py" + }, + { + "phases": [ + { + "change": 0.0006629000417888165, + "collect": 0.01910550007596612, + "snapshot": 0.25747299985960126, + "diagnostics": 1.600012183189392e-06, + "occurrences": 0.018834900110960007, + "total": 0.2960779001004994 + }, + { + "change": 0.0004353998228907585, + "collect": 0.013797500170767307, + "snapshot": 0.17531279986724257, + "diagnostics": 1.700129359960556e-06, + "occurrences": 0.025181000120937824, + "total": 0.21472840011119843 + }, + { + "change": 0.0006742998957633972, + "collect": 0.01689299987629056, + "snapshot": 0.20803740015253425, + "diagnostics": 1.4998950064182281e-06, + "occurrences": 0.018164700362831354, + "total": 0.24377090018242598 + } + ], + "queries": [ + { + "outline.first": 0.03195799980312586, + "lenses.first": 0.002882099710404873, + "inlays.first": 0.06895270012319088, + "folding.first": 0.0884314002469182, + "hover.first_after_auto": 0.04027150012552738, + "outline.warm": 1.700129359960556e-06, + "inlays.warm": 0.00018059974536299706, + "folding.warm": 0.012595599982887506, + "hover.warm": 1.4499761164188385e-05 + }, + { + "outline.first": 0.03889239998534322, + "lenses.first": 0.0019406001083552837, + "inlays.first": 0.0680010998621583, + "folding.first": 0.0851719998754561, + "hover.first_after_auto": 0.05150569975376129, + "outline.warm": 2.100132405757904e-06, + "inlays.warm": 0.00017439993098378181, + "folding.warm": 0.020144999958574772, + "hover.warm": 2.490030601620674e-05 + }, + { + "outline.first": 0.039624900091439486, + "lenses.first": 0.0021291999146342278, + "inlays.first": 0.06554820016026497, + "folding.first": 0.13685300014913082, + "hover.first_after_auto": 0.040453999768942595, + "outline.warm": 1.0998919606208801e-06, + "inlays.warm": 0.00011679995805025101, + "folding.warm": 0.014431999996304512, + "hover.warm": 1.6700010746717453e-05 + } + ], + "memory": { + "snapshot": { + "kept": 9680689, + "peak": 16383918 + }, + "diagnostics_occurrences": { + "kept": 12037970, + "peak": 16383918 + }, + "automatic_queries": { + "kept": 25166628, + "peak": 35604694 + }, + "sparse_hovers": { + "kept": 27793396, + "peak": 35604694 + } + }, + "dimensions": { + "read_files": 1, + "shapes": 7200, + "endpoints": 400, + "captured_files": 0, + "retained_source_records": 0 + }, + "counts": { + "v1.snapshot.yaml_composes": 1, + "input.b0de2b90a79d": 2, + "v1.snapshot.converted_nodes": 32007, + "v1.automatic.yaml_composes": 1, + "v1.automatic.converted_nodes": 32007, + "v2.snapshot.yaml_composes": 1, + "input.7cd2b060b6fd": 2, + "v2.snapshot.converted_nodes": 32007, + "v2.automatic.yaml_composes": 1, + "v2.automatic.converted_nodes": 32007, + "v3.snapshot.yaml_composes": 1, + "input.b2c9a97bfc7a": 2, + "v3.snapshot.converted_nodes": 32007, + "v3.automatic.yaml_composes": 1, + "v3.automatic.converted_nodes": 32007, + "broken.snapshot_composes": 1, + "broken.three_source_request_composes": 1 + }, + "python": "3.12.13 (main, May 10 2026, 19:35:37) [MSC v.1944 64 bit (AMD64)]", + "backend": "libyaml", + "import": "fastraml/__init__.py" + } + ] + } +} diff --git a/docs/reports/2026-10-10/service-shared-indices.md b/docs/reports/2026-10-10/service-shared-indices.md new file mode 100644 index 00000000..7cd58c63 --- /dev/null +++ b/docs/reports/2026-10-10/service-shared-indices.md @@ -0,0 +1,215 @@ +# Lazy semantic, typed-data and outline recovery + +Date: 2026-10-10. Experiment branch: `perf/service-shared-indices`. Control: +`39121181e5eaede7aa1d5c6566bd82aed32ff5e5`, the merged model-backed inlay recovery. +Scope and acceptance are written before implementation and profiling. + +## 1. Goal and current repeated work + +Remove repeated model enumeration for hierarchy/navigation and repeated outline +construction within a snapshot. Keep diagnostics-only rebuilds free of this work. +This is a bounded recovery of the second candidate in the +[recovery plan](../../research/2026-10-10/service-recovery-plan.md), using the +accepted plain-source cache. Record-backed source and accurate section sites are +separate candidates. + +Source inspection of the control shows: + +- `type_at` and hierarchy-item rebinding linearly enumerate declarations/shapes + to find an occurrence target. Each subtype request scans all declarations again. +- Workspace symbols, hover subjects and each outline independently enumerate + declarations; warm outlines reconstruct all symbols and authored-member lists. +- A typed-data definition fallback initializes the entire hover subject/site + index before querying `DataHover`, even though it needs no formatted hover. +- Typed-data matching already exists in `types/navigation.py` and `DataHover`; + recover ownership/separation, not another implementation of its rules. + +The previous phase evidence measures a warm outline at about 30 ms on the +311,308-byte/13,204-line single-file corpus. New workload timing establishes the +navigation request sequence before implementation; counters check enumeration and +population separately from elapsed-time measurements. + +## 2. Workloads and cache lifecycle + +The existing snapshot-first and source-first mixed sequences cover two outlines +per version over three edits, plus viewport inlays, sparse hover and structural +requests. Results are released before the next version; the returned workspace +keeps only its current snapshot and caches. These are representative service-core +sequences, not observed client traces (docs/12 § 4.2). + +Add `service-navigation/unwrap` over the same 400-family single-file corpus: +three comment-edit versions; diagnostics and occurrences; six fixed sparse type +preparations, supertype and subtype requests; three typed-data definitions before +hover presentation; two outlines and two workspace-symbol searches per version. +Probe selection/text generation is outside timing. Workspace creation, edits, +collection, snapshot construction and requests are inside. Discard request answers +and keep the current workspace. Request count is fixed as input width scales, so +the benchmark does not silently measure every type against every declaration. + +Proposed ownership: + +- Snapshot semantic object: lazy, with declaration enumeration/file grouping + populated on demand and shared by symbols, outline and hover. ID lookup is a + separately lazy map; reverse hierarchy is separately lazy, built once from + declared `inherits`/`alias` edges. No hierarchy work merely for inlays/outline. +- Snapshot typed-data object: lazy, shared by definition, hover and inlays. + Navigation may populate it without semantic presentation or source composition. + Formatting remains owned by hover. +- Snapshot outline cache: canonical model URI → borrowed, read-only symbol list, + including empty results. Populate on the first outline request for that file. + Preserve current authored positions/ordering, including partial snapshots. +- All these caches are snapshot-local. Root or dependency changes replace them; + unchanged source composition can still survive through the workspace owner. + Several roots have distinct semantic contexts/caches. A held old snapshot keeps + its own answers; no cache can return another version's outline or bound ID. + +Expected work is one enumeration per populated cache boundary, then ID lookup, +direct-parent reads and child/output traversal per query. Reverse-edge construction +is proportional to declared shapes and parent edges, not shape pairs. Data visits +retain the existing finite-value/model traversal. Outlines still do one full +authored construction per requested file/snapshot; warm queries borrow its result. + +## 3. Acceptance and verification plan + +Protect lazy population, cross-query reuse, aliases/multiple inheritance and +declaration order; typed definitions before hover; empty/partial outlines; root and +dependency edits; old snapshots and separate roots. Meaningful reach tests must +cover both timed and allocation navigation passes and the unchanged mixed orders. + +Compare against `3912118`: `service-navigation`, both mixed orders, hover, inlays +and `large/service`. Run linearity for the added workload and changed mixed +scenarios. Measure first and warm requests separately from diagnostics/rebuild, +and report peak/retained cache costs. No repeatable recurring rebuild regression +beyond noise is acceptable. Material cold-query or retained-memory trade-offs +require an explicit decision; a warm-query speedup does not hide them. + +Run the complete Ruff/format/mypy/pytest gate and platform CI before integration. +Stop when this bounded goal is met; further source lookup work is a separate +experiment. Results and the acceptance decision will be appended below. + +## 4. Implementation and correctness results + +The semantic owner has separately lazy declaration/file, ID and reverse-parent +caches. Outlines, workspace symbols and hover share declaration enumeration. +Typed-data root enumeration moved to `datahover.py`; the snapshot owns one lazy +`DataHover` shared by definitions, hover and inlays. Definitions no longer create +hover presentation. Outlines cache complete read-only borrowed results by URI, +including empty results, without adding source-site capture. + +A multiple-inheritance test exposed a pre-existing hierarchy limitation: scalar +parent wrappers were returned as list-token sites, and reverse queries missed the +named declarations they alias. The hierarchy reader now follows the wrapper's +bound alias; a genuinely declared alias remains a named item. This reads model +edges and does not resolve names or add a parser rule. Tests preserve direct +parent order and child declaration order, including aliases and multiple parents. + +The sparse navigation reach test proves one declaration enumeration, one data +population and one outline construction per snapshot in both measurement passes. +It forbids hover presentation/source construction. Separate cases cover idle +diagnostics, cross-query data reuse, empty outlines, root/dependency invalidation, +old snapshots and distinct roots reading one library. The existing partial-model, +outline/TCK and inlay tests still pass. + +Windows gate: Ruff, formatting, strict mypy and **5,876 passed, 69 skipped, +1 xfailed**, with the pinned TCK initialized. The added linearity test accounts +for the additional ordinary-suite skip when benchmarks are not enabled. +Focused cache, typed-data, inlay and navigation/mixed-reach suites also pass with +the forced pure-Python YAML plugin: **75 passed**. + +## 5. Controlled costs and scaling + +Control: `3912118`. Windows AMD64, Python 3.12, libyaml, identical generated +inputs. Three alternating A/B rounds, best of three, except the final mixed +comparison uses best of nine after substantial timing spread in the first run. +The navigation scenario's production-unchanged preimplementation run was 865.5 ms; +the controlled A/B is the acceptance measurement. Times cover three edit/query +cycles in the navigation and mixed rows, not one request. + +| Workload/configuration | Control ms | Candidate ms | Time/noise | Peak MB, control → candidate | Kept MB, control → candidate | +|---|---:|---:|---|---:|---:| +| `service-navigation/unwrap` | 835.2 | 692.9 | -17.0%, noise 4.1% | 17.041 → 16.400 (-3.8%) | 14.770 → 15.191 (+2.9%) | +| `service-session/unwrap`, repeat 9 | 1326.5 | 1216.9 | -8.3%, noise 6.9% | 33.686 → 35.620 (+5.7%) | 25.874 → 27.808 (+7.5%) | +| `service-source-first/unwrap`, repeat 9 | 1350.0 | 1254.0 | within 19.4% noise | 28.132 → 29.094 (+3.4%) | 25.874 → 27.808 (+7.5%) | +| `hover/unwrap` | 330.5 | 345.7 | within 39.7% noise | 30.121 → 30.294 (+0.6%) | 26.425 → 26.599 (+0.7%) | +| `inlays/unwrap` | 253.9 | 258.0 | within 6.9% noise | 19.570 → 19.743 (+0.9%) | 18.132 → 18.305 (+1.0%) | +| `large/parse` | 339.5 | 339.4 | within 18.4% noise | 20.231 → 20.232 (within spread) | 19.766 → 19.767 (within spread) | +| `large/unwrap+validate` | 395.3 | 395.5 | within 4.7% noise | 20.814 → 20.814 | 19.873 → 19.873 | +| `large/service` | 550.2 | 512.1 | measured -6.9%, noise 4.3%; see limit below | 35.803 → 35.804 (+0.0%) | 28.623 → 28.624 (+0.0%) | + +The last row establishes no rebuild regression in this run. It is not attributed +to semantic/query caching: the reach case proves those caches remain unpopulated +through diagnostics/occurrences, and parsing itself is unchanged. The first mixed +run had 71.4%/15.2% time spread and showed no accepted time delta; its allocation +deltas matched the final run. Timing spread also prevents a cold hover speed claim. + +Final linearity, best of nine: + +| Workload | Full / half ms | Normalized time | Peak | Retained | +|---|---:|---:|---:|---:| +| `service-navigation` | 698.0 / 353.0 | 0.989 | 0.994 | 0.996 | +| `service-session` | 1207.2 / 604.1 | 0.999 | 0.991 | 0.998 | +| `service-source-first` | 1238.3 / 633.1 | 0.978 | 0.989 | 0.998 | + +Reproduce the controlled comparisons and scaling: + +```powershell +uv run python -m bench ab 3912118 --bench service-navigation --bench service-session --bench service-source-first --config unwrap --rounds 3 --repeat 3 +uv run python -m bench ab 3912118 --bench service-session --bench service-source-first --config unwrap --rounds 3 --repeat 9 +uv run python -m bench ab 3912118 --bench hover --bench inlays --config unwrap --rounds 3 --repeat 3 +uv run python -m bench ab 3912118 --bench large --config parse --config unwrap+validate --config service --rounds 3 --repeat 3 +uv run python -m bench linearity --bench service-navigation --bench service-session --bench service-source-first --repeat 9 +``` + +## 6. First-use and retained ownership + +[Raw phase evidence](service-shared-indices-phase-metrics.json) comes from the +existing driver, alternating three rounds/best of three. The baseline worktree +is `perf/service-model-inlays` at `8e0ef3b`, production-identical to merged +`3912118`. Both retain zero authoring records and compose once semantically and +once for shared source per version. Timing spread is 60.2%/67.8%, so these phase +minima are diagnostic boundaries, not independent accepted A/B improvements. + +First outline remains about 31.7/32.0 ms; warm outline changes from about 34.0 ms +to 0.0011 ms because it borrows the cached tree. Other first-use work remains lazy: +inlays populate model/data presentation, folding composes shared source, hover +populates grammar. The snapshot-first allocation peak still occurs during source +composition, now with the retained outline as well as inlay/data caches alive. + +Post-collection phase-driver retention: + +| Boundary | Control MB | Candidate MB | +|---|---:|---:| +| Snapshot | 9.683 | 9.681 | +| Diagnostics/occurrences | 12.041 | 12.038 | +| Automatic queries | 23.235 | 25.167 | +| Sparse hovers afterward | 25.862 | 27.793 | + +The additional mixed retention is about 1.93 MB. A separate ownership probe +keeps the current workspace but releases only its cached outline after collection: +27.814 MB → 26.074 MB, a **1.740 MB outline-owned difference**. The remaining +increase is chiefly shared declaration/file metadata. This is genuine snapshot +retention, unlike the previous inlay experiment's temporary construction overlap. +It is released when the snapshot is no longer retained; distinct root contexts +may each retain an outline. The measured single-file scenarios do not establish +multi-root RSS or dependency-edit latency. + +Reproduce phase evidence and the ownership check: + +```powershell +uv run python docs/reports/2026-10-10/service_cost_probe.py --corpus hover --tree "baseline=../service-model-inlays" --tree "shared-indices=." --rounds 3 --repeat 3 --output docs/reports/2026-10-10/service-shared-indices-phase-metrics.json +uv run python docs/reports/2026-10-10/service_index_allocation_probe.py +``` + +## 7. Decision boundary + +Correctness, reach and scaling pass. The sparse semantic workflow improves 17.0% +and snapshot-first mixed time improves 8.3%, while diagnostics-only retained cost +is effectively unchanged. The complete bundle costs **7.5% more retained mixed +allocation**, about 1.93 MB per current snapshot on this corpus, primarily its +requested-file outline tree. This is a retained-memory cost, separate from the +previous inlay experiment's temporary construction peak. + +The full lazy semantic/hierarchy, typed-data and outline recovery is accepted +with the documented retention +trade-off. Normal CI/platform checks remain the merge gate. No unmeasured eviction +policy or source-record architecture is proposed to hide the cost. diff --git a/docs/reports/2026-10-10/service_index_allocation_probe.py b/docs/reports/2026-10-10/service_index_allocation_probe.py new file mode 100644 index 00000000..6e506d2a --- /dev/null +++ b/docs/reports/2026-10-10/service_index_allocation_probe.py @@ -0,0 +1,47 @@ +"""Attribute retained allocation to the requested-file outline cache only. + +This runs the unchanged mixed driver with the harness's paused-collector policy. +It reports ownership, never instrumented elapsed time. +""" + +import gc +import json +import sys +import tempfile +import tracemalloc +from pathlib import Path + +sys.path.insert(0, str(Path.cwd())) + +from bench import corpus +from bench.service_session import exercise, prepare + + +def main(): + with tempfile.TemporaryDirectory(prefix='index-allocation-') as directory: + prepared = prepare(corpus.write_hover(Path(directory), family_count=400)) + exercise(prepared) + gc.collect() + enabled = gc.isenabled() + gc.disable() + tracemalloc.start() + try: + workspace, counts = exercise(prepared) + assert counts['snapshots'] == 3 + gc.collect() + before = tracemalloc.get_traced_memory()[0] + snapshot = workspace.snapshot(prepared.root) + snapshot.outlines.clear() + gc.collect() + after = tracemalloc.get_traced_memory()[0] + finally: + tracemalloc.stop() + if enabled: + gc.enable() + print(json.dumps({'kept_before_MB': before / 1e6, + 'kept_after_outline_release_MB': after / 1e6, + 'outline_owned_MB': (before - after) / 1e6}, indent=2)) + + +if __name__ == '__main__': + main() diff --git a/docs/research/2026-10-10/service-recovery-plan.md b/docs/research/2026-10-10/service-recovery-plan.md index 14871c34..5f9532c1 100644 --- a/docs/research/2026-10-10/service-recovery-plan.md +++ b/docs/research/2026-10-10/service-recovery-plan.md @@ -135,8 +135,16 @@ Current execution: The bounded port now has correctness, reach, A/B and scaling evidence in the [inlay report](../../reports/2026-10-10/service-model-inlays.md). Its snapshot-first mixed allocation peak adds 10.0%, an accepted construction-order trade-off. - The normal CI/platform gate applies before merge. Remaining recovery candidates - stay separate experiments against the integrated baseline. + All CI checks passed; PR #3 merged at `3912118`. +- Stage C, second bundle: `perf/service-shared-indices` starts at `3912118`. + Its lazy semantic/hierarchy, typed-data ownership and outline-cache scope is + documented before implementation in the + [shared-index report](../../reports/2026-10-10/service-shared-indices.md). + The implemented bundle passes local correctness/reach/scaling checks and improves + sparse navigation 17.0%; its 7.5% mixed retained-allocation increase is attributed + mainly to the requested-file outline cache. The full bundle is accepted with + that retention trade-off, subject to the normal CI/platform gate. + Remaining candidates stay separate experiments against the integrated baseline. Update these entries as each stage completes. Keep measurements in the dated report, current pending work in docs/15, and execution history here. diff --git a/fastraml/service/datahover.py b/fastraml/service/datahover.py index 371c8e91..89fcaee6 100644 --- a/fastraml/service/datahover.py +++ b/fastraml/service/datahover.py @@ -12,6 +12,7 @@ from typing import TYPE_CHECKING from fastraml.types.base import Property +from fastraml.types.examples import examples_of from fastraml.types.navigation import children if TYPE_CHECKING: @@ -19,6 +20,7 @@ from fastraml.datanode import DataNode, ValueNode from fastraml.positions import Position + from fastraml.registry import Raml from fastraml.types.base import BaseShape from fastraml.types.navigation import TypedChild @@ -42,6 +44,27 @@ class DataTarget: is_key: bool = False +def data_roots(raml: Raml) -> Iterator[DataRoot]: + """Bound value roots, independent of hover subject indexing or formatting.""" + for extension in raml.domain_extensions: + if extension.defined_by is not None: + yield DataRoot(extension.value, extension.defined_by, extension.name, 'annotation value') + for value, declarations in raml.custom_facet_refs.items(): + for prop in declarations: + yield DataRoot(value, prop.base, prop.name, 'custom facet value') + for base in raml.shapes: + name = base.name or '' + if base.alias is not None: + continue # aliases share their referent's data; index it at the referent + if base.default is not None: + yield DataRoot(base.default, base, name, 'default value') + for value in base.enum or (): + yield DataRoot(value, base, name, 'enum value') + for example in examples_of(base): + if example.data is not None: + yield DataRoot(example.data, base, name, 'example value') + + class DataHover: """A lazy, once-per-snapshot token index over typed structured values.""" diff --git a/fastraml/service/hover.py b/fastraml/service/hover.py index 48138e15..d7241206 100644 --- a/fastraml/service/hover.py +++ b/fastraml/service/hover.py @@ -16,13 +16,14 @@ from typing import TYPE_CHECKING, Final from fastraml.errors import RamlError -from fastraml.parser.fragments import APIFragment, LibraryLink, every_declaration +from fastraml.parser.fragments import APIFragment, LibraryLink from fastraml.parser.includes import is_json_ref from fastraml.parser.security import SecuritySchemeDefinition from fastraml.parser.syntax import METHODS, NAME_MAPS, Key, Site, child_site, fragment_site, keys from fastraml.parser.templates import TemplateDefinition -from fastraml.service.datahover import DataHover, DataRoot, DataTarget +from fastraml.service.datahover import DataHover, DataTarget, data_roots from fastraml.service.hoverdocs import BUILTINS, METHOD_DOCS, field_doc +from fastraml.service.index import SemanticIndex from fastraml.service.inlays import ( LABEL_LIMIT, Hint, @@ -36,7 +37,6 @@ from fastraml.service.text import Lines from fastraml.types.base import BaseShape, Parameter, facets_of from fastraml.types.complex_ import ArrayShape, ObjectShape, UnionShape, UnknownShape -from fastraml.types.examples import examples_of from fastraml.types.expressions import Array, Optional_, Primitive, Union, parse_expression from fastraml.types.jsonschema_ import JsonShape from fastraml.types.scalars import DATETIME_FORMATS, INTEGER_FORMATS, NUMBER_FORMATS @@ -127,6 +127,7 @@ class Hover: '_resources', '_responses', '_root', + '_semantic', '_sites', '_source_backend', '_source_builtins', @@ -141,11 +142,14 @@ def __init__( # noqa: PLR0913 - the snapshot's borrowed source owner and compos root: str, occurrences: Occurrences, *, + semantic: SemanticIndex | None = None, + data: DataHover | None = None, sources: ReferenceType[Sources] | None = None, source_generation: int = 0, source_backend: str = '', ) -> None: self._raml = raml + self._semantic = semantic if semantic is not None else SemanticIndex(raml) self._root = root.rpartition('/')[0] + '/' self._occurrences = occurrences self._sources = sources @@ -179,7 +183,7 @@ def __init__( # noqa: PLR0913 - the snapshot's borrowed source owner and compos self._data_descriptions: dict[DataTarget, str] = {} self._inlay_declarations: dict[str, tuple[list[Hint], list[tuple[int, int]]]] = {} self._index() - self._data = DataHover(self._data_roots()) + self._data = data if data is not None else DataHover(data_roots(raml)) def _put(self, subject: _Subject) -> None: entity = subject.entity @@ -201,7 +205,7 @@ def _put(self, subject: _Subject) -> None: def _index(self) -> None: raml = self._raml borrowed: list[BaseShape] = [] - for key, name, entity in every_declaration(raml): + for key, name, entity in self._semantic.declarations: kind = DECLARATION_KINDS[key] self._put(_Subject(name, _role(kind), entity)) for fragment in raml.fragments.values(): @@ -589,35 +593,6 @@ def _custom_facet_doc(self, base: BaseShape, name: str) -> str | None: texts = [self._describe(subject) for subject in subjects if subject is not None] return '\n\n---\n\n'.join(texts) if texts else None - def _data_roots(self) -> Iterator[DataRoot]: - for extension in self._raml.domain_extensions: - if extension.defined_by is not None: - yield DataRoot( - extension.value, - extension.defined_by, - extension.name, - 'annotation value', - ) - for value, declarations in self._raml.custom_facet_refs.items(): - for prop in declarations: - yield DataRoot(value, prop.base, prop.name, 'custom facet value') - for base in self._raml.shapes: - name = base.name or '' - if base.alias is not None: - continue # an alias shares its referent's data; the referent indexes it - if base.default is not None: - yield DataRoot(base.default, base, name, 'default value') - for value in base.enum or (): - yield DataRoot(value, base, name, 'enum value') - for example in examples_of(base): - if example.data is not None: - yield DataRoot( - example.data, - base, - name, - 'example value', - ) - def _data_doc(self, target: DataTarget) -> str: cached = self._data_descriptions.get(target) if cached is not None: diff --git a/fastraml/service/index.py b/fastraml/service/index.py new file mode 100644 index 00000000..bb9bf6be --- /dev/null +++ b/fastraml/service/index.py @@ -0,0 +1,84 @@ +"""Lazy snapshot lookups over parser-bound declarations (docs/21 § 4).""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from fastraml.parser.fragments import LibraryLink, every_declaration +from fastraml.parser.security import SecuritySchemeDefinition +from fastraml.parser.templates import TemplateDefinition +from fastraml.types.base import BaseShape + +if TYPE_CHECKING: + from collections.abc import Iterator + + from fastraml.parser.fragments import Declaration + from fastraml.registry import Raml + +type Entity = BaseShape | LibraryLink | TemplateDefinition | SecuritySchemeDefinition + + +def parents_of(base: BaseShape) -> Iterator[BaseShape]: + """The direct parent edges the parser records, including an alias's referent.""" + for parent in base.inherits: + # A multiple-inheritance scalar is a positioned wrapper whose key is + # the expression itself. Navigate its bound alias to the declaration; + # a genuinely declared alias has a separate authored key and stays named. + if parent.alias is not None and parent.type_expr is not None and parent.key_pos == parent.type_expr.position: + yield parent.alias + else: + yield parent + if base.alias is not None: + yield base.alias + + +class SemanticIndex: + """Query-specific caches; constructing this owner enumerates no model data.""" + + __slots__ = ('_by_id', '_by_uri', '_children', '_declarations', '_raml') + + def __init__(self, raml: Raml) -> None: + self._raml = raml + self._declarations: tuple[tuple[str, str, Declaration], ...] | None = None + self._by_id: dict[int, Entity] | None = None + self._by_uri: dict[str, list[tuple[str, str, Declaration]]] | None = None + self._children: dict[int, list[BaseShape]] | None = None + + @property + def declarations(self) -> tuple[tuple[str, str, Declaration], ...]: + if self._declarations is None: + self._declarations = tuple(every_declaration(self._raml)) + return self._declarations + + @property + def by_id(self) -> dict[int, Entity]: + if self._by_id is None: + found: dict[int, Entity] = {} + for base in self._raml.shapes: + found.setdefault(base.id, base) + for _, _, entity in self.declarations: + found[entity.id] = entity + for fragment in self._raml.fragments.values(): + found.update((link.id, link) for link in fragment.uses.values()) + self._by_id = found + return self._by_id + + @property + def by_uri(self) -> dict[str, list[tuple[str, str, Declaration]]]: + if self._by_uri is None: + found: dict[str, list[tuple[str, str, Declaration]]] = {} + for entry in self.declarations: + found.setdefault(entry[2].location, []).append(entry) + self._by_uri = found + return self._by_uri + + @property + def children(self) -> dict[int, list[BaseShape]]: + if self._children is None: + found: dict[int, list[BaseShape]] = {} + for _, _, entity in self.declarations: + if isinstance(entity, BaseShape): + for parent in {parent.id for parent in parents_of(entity)}: + found.setdefault(parent, []).append(entity) + self._children = found + return self._children diff --git a/fastraml/service/outline.py b/fastraml/service/outline.py index 38313ab7..1aa708cd 100644 --- a/fastraml/service/outline.py +++ b/fastraml/service/outline.py @@ -31,6 +31,13 @@ def document_symbols(snapshot: Snapshot, uri: str) -> list[Symbol]: + """Borrow the read-only outline cached for this URI and snapshot.""" + if uri not in snapshot.outlines: + snapshot.outlines[uri] = _document_symbols(snapshot, uri) + return snapshot.outlines[uri] + + +def _document_symbols(snapshot: Snapshot, uri: str) -> list[Symbol]: """The outline of `uri`: its metadata, its `uses:`, a section per declaration table, its documentation and its resources. @@ -44,7 +51,8 @@ def document_symbols(snapshot: Snapshot, uri: str) -> list[Symbol]: added there. """ raml = snapshot.raml - if raml is None or uri not in raml.fragments: + semantic = snapshot.semantic + if raml is None or semantic is None or uri not in raml.fragments: return [] found: list[Symbol | None] = [ symbol(name, SymbolKind.METADATA, facet, facet.value) for name, facet in authored.metadata(raml, uri) @@ -55,7 +63,7 @@ def document_symbols(snapshot: Snapshot, uri: str) -> list[Symbol]: uses = (symbol(name, SymbolKind.LIBRARY, link, link.value) for name, link in authored.uses(raml, uri).items()) found.append(_group('uses', uses)) sections: dict[str, list[Symbol | None]] = {} - for key, name, entity in authored.declarations(raml, uri): + for key, name, entity in semantic.by_uri.get(uri, ()): sections.setdefault(key, []).append(_declaration(name, DECLARATION_KINDS[key], entity)) found += (_group(key, entries) for key, entries in sections.items()) found += (_resource(written) for written in authored.resources(raml, uri)) diff --git a/fastraml/service/queries.py b/fastraml/service/queries.py index 6dfb1a43..77bb9ed4 100644 --- a/fastraml/service/queries.py +++ b/fastraml/service/queries.py @@ -17,8 +17,8 @@ from typing import TYPE_CHECKING, Any, Final from fastraml.errors import RamlError -from fastraml.parser.fragments import LibraryLink, every_declaration from fastraml.positions import Position +from fastraml.service.index import parents_of from fastraml.types.base import BaseShape from fastraml.views.occurrences import DECLARATION_KINDS as _OCCURRENCE_KINDS from fastraml.views.occurrences import Kind, Link, Role @@ -29,8 +29,6 @@ from collections.abc import Iterable, Iterator, Mapping, Sequence from fastraml.errors import Trace - from fastraml.parser.fragments import Declaration - from fastraml.registry import Raml from fastraml.service.workspace import Snapshot from fastraml.views.authored import Placed from fastraml.views.lint import Finding @@ -258,8 +256,8 @@ def definition(snapshot: Snapshot, uri: str, line: int, column: int) -> list[Sit found.append(Site(occurrence.resolved, _START)) elif occurrence.target is not None: found += [_site(other) for other in occurrences.of(occurrence.target) if other.role is Role.DEFINITION] - if not found and snapshot.hover is not None: - for target in snapshot.hover.data_at(uri, line, column): + if not found and snapshot.data is not None: + for target in snapshot.data.at(uri, line, column): base = target.base if base.key_pos.is_known: found.append(Site(base.location, base.key_pos.within(base.name or target.name))) @@ -313,18 +311,6 @@ def hover(snapshot: Snapshot, uri: str, line: int, column: int) -> tuple[str, Po return None if context is None else context.at(uri, line, column) -def _entity(raml: Raml, target: int) -> Declaration | LibraryLink | None: - """The declaration, `uses:` entry or shape with the id `target`.""" - for fragment in raml.fragments.values(): - for link in fragment.uses.values(): - if link.id == target: - return link - for _key, _name, entity in every_declaration(raml): - if entity.id == target: - return entity - return next((base for base in raml.shapes if base.id == target), None) - - # -- symbols ------------------------------------------------------------------------ #: The symbol kind of each declaration table, by the key it is written under. @@ -363,10 +349,10 @@ def workspace_symbols(snapshots: Iterable[Snapshot], query: str) -> list[Symbol] seen: set[tuple[str, int, int]] = set() found: list[Symbol] = [] for snapshot in snapshots: - raml = snapshot.raml - if raml is None: + semantic = snapshot.semantic + if semantic is None: continue - for table, name, entity in every_declaration(raml): + for table, name, entity in semantic.declarations: each = symbol(name, DECLARATION_KINDS[table], entity) if wanted in name.casefold() else None if each is None: continue @@ -497,12 +483,12 @@ def _pairs(root: Node) -> Iterator[tuple[Node, Node]]: def type_at(snapshot: Snapshot, uri: str, line: int, column: int) -> Symbol | None: """The declared type the name under the cursor stands for.""" - raml = snapshot.raml - if raml is None: + semantic = snapshot.semantic + if semantic is None: return None for occurrence in _at(snapshot, uri, line, column): if occurrence.kind in {Kind.TYPE, Kind.ANNOTATION_TYPE} and occurrence.target is not None: - entity = _entity(raml, occurrence.target) + entity = semantic.by_id.get(occurrence.target) if isinstance(entity, BaseShape): return _type_symbol(entity) return None @@ -513,41 +499,28 @@ def supertypes(snapshot: Snapshot, item: Symbol) -> list[Symbol]: base = _declared(snapshot, item) if base is None: return [] - return [each for parent in _parents(base) if (each := _type_symbol(parent)) is not None] + return [each for parent in parents_of(base) if (each := _type_symbol(parent)) is not None] def subtypes(snapshot: Snapshot, item: Symbol) -> list[Symbol]: """The declared types that name `item` in their `type:`.""" base = _declared(snapshot, item) - raml = snapshot.raml - if base is None or raml is None: + semantic = snapshot.semantic + if base is None or semantic is None: return [] - return [ - each - for _key, _name, child in every_declaration(raml) - if isinstance(child, BaseShape) - and any(parent.id == base.id for parent in _parents(child)) - and (each := _type_symbol(child)) is not None - ] - - -def _parents(base: BaseShape) -> Iterator[BaseShape]: - """The named types `base` names in its `type:`, an alias's referent among them.""" - yield from base.inherits - if base.alias is not None: - yield base.alias + return [each for child in semantic.children.get(base.id, ()) if (each := _type_symbol(child)) is not None] def _declared(snapshot: Snapshot, item: Symbol) -> BaseShape | None: """The type `item` names, found again by where its name is written: an item may come from an earlier snapshot (docs/21 § 4). """ - raml = snapshot.raml - if raml is None: + semantic = snapshot.semantic + if semantic is None: return None for occurrence in _at(snapshot, item.uri, item.selection.line, item.selection.column): if occurrence.role is Role.DEFINITION and occurrence.target is not None: - entity = _entity(raml, occurrence.target) + entity = semantic.by_id.get(occurrence.target) if isinstance(entity, BaseShape): return entity return None diff --git a/fastraml/service/workspace.py b/fastraml/service/workspace.py index 77559479..d4c6d88a 100644 --- a/fastraml/service/workspace.py +++ b/fastraml/service/workspace.py @@ -30,7 +30,9 @@ from fastraml.loaders import SafeFileLoader from fastraml.parser.entry import ParseOptions, parse_lenient from fastraml.parser.fragments import FragmentKind, identify_fragment +from fastraml.service.datahover import DataHover, data_roots from fastraml.service.hover import Hover +from fastraml.service.index import SemanticIndex from fastraml.service.lenses import EffectiveViews from fastraml.service.source import Sources, original_tree from fastraml.service.text import Lines @@ -45,6 +47,7 @@ from fastraml.config import FastRamlConfig from fastraml.registry import Raml + from fastraml.service.queries import Symbol from fastraml.views.lint import Finding, Linter from fastraml.views.occurrences import Occurrences from fastraml.yamlnode import Node @@ -106,10 +109,25 @@ class Snapshot: _findings: list[Finding] | None = field(default=None, repr=False) _hover: Hover | None = field(default=None, repr=False) _effective_views: EffectiveViews | None = field(default=None, repr=False) + _semantic: SemanticIndex | None = field(default=None, repr=False) + _data: DataHover | None = field(default=None, repr=False) + outlines: dict[str, list[Symbol]] = field(default_factory=dict, repr=False) sources: ReferenceType[Sources] | None = field(default=None, repr=False) source_generation: int = field(default=0, repr=False) source_backend: str = field(default='', repr=False) + @property + def semantic(self) -> SemanticIndex | None: + if self._semantic is None and self.raml is not None: + self._semantic = SemanticIndex(self.raml) + return self._semantic + + @property + def data(self) -> DataHover | None: + if self._data is None and self.raml is not None: + self._data = DataHover(data_roots(self.raml)) + return self._data + @property def effective_views(self) -> EffectiveViews | None: if self._effective_views is None and self.raml is not None: @@ -125,6 +143,8 @@ def hover(self) -> Hover | None: self.raml, self.root, occurrences, + semantic=self.semantic, + data=self.data, sources=self.sources, source_generation=self.source_generation, source_backend=self.source_backend, diff --git a/tests/bench/test_corpus.py b/tests/bench/test_corpus.py index 87c7de42..4d09bb0a 100644 --- a/tests/bench/test_corpus.py +++ b/tests/bench/test_corpus.py @@ -55,6 +55,7 @@ 'source-structure': lambda root: corpus.write_hover(root, family_count=3), 'service-session': lambda root: corpus.write_hover(root, family_count=3), 'service-source-first': lambda root: corpus.write_hover(root, family_count=3), + 'service-navigation': lambda root: corpus.write_hover(root, family_count=3), } diff --git a/tests/bench/test_service_navigation.py b/tests/bench/test_service_navigation.py new file mode 100644 index 00000000..21342abd --- /dev/null +++ b/tests/bench/test_service_navigation.py @@ -0,0 +1,60 @@ +"""The sparse navigation scenario reaches queries in both measurement passes.""" + +import pytest + +from bench import corpus +from bench.__main__ import run_one +from fastraml.service import index as index_module +from fastraml.service import outline, queries +from fastraml.service.datahover import DataHover +from fastraml.service.hover import Hover +from fastraml.service.workspace import Workspace + + +@pytest.mark.parametrize('count', [1, 2, 4]) +def test_navigation_reaches_fixed_requests_and_edit_boundaries(tmp_path, monkeypatch, count): + entry = corpus.write_hover(tmp_path, family_count=count) + versions = [] + requests = dict.fromkeys(('type_at', 'supertypes', 'subtypes', 'definition', 'workspace_symbols', 'outlines'), 0) + populations = dict.fromkeys(('declarations', 'data', 'outline'), 0) + original_parse = Workspace._parse + + def parse(workspace, uri): + versions.append(workspace.buffers[uri].version) + return original_parse(workspace, uri) + + def counted(tally, name, operation): + def request(*args, **kwargs): + tally[name] += 1 + return operation(*args, **kwargs) + + return request + + def declarations(raml): + populations['declarations'] += 1 + yield from original_declarations(raml) + + def presentation(*args, **kwargs): + pytest.fail('navigation and cached outlines must not initialize hover presentation or compose source') + + original_declarations = index_module.every_declaration + monkeypatch.setattr(index_module, 'every_declaration', declarations) + monkeypatch.setattr(Hover, '_index', presentation) + monkeypatch.setattr(Hover, '_node', presentation) + for owner, name, counter in ((DataHover, '_index', 'data'), (outline, '_document_symbols', 'outline')): + monkeypatch.setattr(owner, name, counted(populations, counter, getattr(owner, name))) + monkeypatch.setattr(Workspace, '_parse', parse) + for name in requests: + owner, attribute = (outline, 'document_symbols') if name == 'outlines' else (queries, name) + monkeypatch.setattr(owner, attribute, counted(requests, name, getattr(owner, attribute))) + run_one('service-navigation', 'unwrap', entry, repeat=1) + assert versions == [1, 2, 3, 1, 2, 3] + assert requests == { + 'type_at': 36, + 'supertypes': 36, + 'subtypes': 36, + 'definition': 18, + 'workspace_symbols': 12, + 'outlines': 12, + } + assert populations == {'declarations': 6, 'data': 6, 'outline': 6} diff --git a/tests/unit/test_service_indices.py b/tests/unit/test_service_indices.py new file mode 100644 index 00000000..1ca84950 --- /dev/null +++ b/tests/unit/test_service_indices.py @@ -0,0 +1,161 @@ +"""Snapshot query caches share work without moving it into diagnostics.""" + +import pytest + +from fastraml.positions import Position +from fastraml.service import index as index_module +from fastraml.service import inlays, outline, queries +from fastraml.service.datahover import DataHover +from fastraml.service.hover import Hover +from tests.unit.test_service_data_hover import DOCUMENT +from tests.unit.test_service_queries import API, COVER, LIBRARY, _buffered, _where + + +def test_diagnostics_do_not_populate_query_indices_and_declaration_queries_share_one_enumeration( + memory_workspace, monkeypatch +): + workspace, folder = _buffered(memory_workspace, {'api.raml': API, 'lib.raml': LIBRARY, 'cover.raml': COVER}) + uri = f'{folder}/api.raml' + snapshot = workspace.snapshot(uri) + queries.diagnostics(snapshot, lint=False) + assert snapshot.occurrences is not None + assert snapshot._semantic is None + assert snapshot._data is None + assert snapshot._hover is None + assert not snapshot.outlines + enumerations = 0 + original = index_module.every_declaration + + def declarations(raml): + nonlocal enumerations + enumerations += 1 + yield from original(raml) + + monkeypatch.setattr(index_module, 'every_declaration', declarations) + first = outline.document_symbols(snapshot, uri) + assert first + assert outline.document_symbols(snapshot, uri) is first + queries.workspace_symbols([snapshot], 'Book') + assert snapshot.hover is not None + assert enumerations == 1 + assert snapshot.semantic is not None + assert snapshot.semantic._by_id is None + assert snapshot.semantic._children is None + + +def test_typed_definition_before_hover_shares_one_data_population_without_presentation(memory_workspace, monkeypatch): + workspace, folder = _buffered(memory_workspace, {'api.raml': DOCUMENT}) + uri = f'{folder}/api.raml' + snapshot = workspace.snapshot(uri) + line, column = _where(DOCUMENT, 'name: Grace') + populations = 0 + original = DataHover._index + + def populate(context): + nonlocal populations + populations += 1 + return original(context) + + def presentation(*args, **kwargs): + pytest.fail('typed navigation must not initialize hover presentation') + + monkeypatch.setattr(DataHover, '_index', populate) + with monkeypatch.context() as isolated: + isolated.setattr(Hover, '_index', presentation) + assert queries.definition(snapshot, uri, line, column) + assert queries.definition(snapshot, uri, line, column + len('name: ')) + assert snapshot._hover is None + assert snapshot._semantic is None + assert queries.hover(snapshot, uri, line, column) is not None + assert inlays.inlay_hints(snapshot, uri, Position(line, column, line, column + 4)) + assert snapshot.hover._data is snapshot.data + assert populations == 1 + + +def test_reverse_hierarchy_preserves_aliases_multiple_parents_and_declaration_order(memory_workspace): + document = ( + '#%RAML 1.0\ntitle: T\ntypes:\n A: object\n B: object\n' + ' Alias: A\n Both: {type: [B, A]}\n Last: {type: A}\n ViaAlias: {type: Alias}\n' + ' Duplicate: {type: [A, A]}\n' + ) + workspace, folder = _buffered(memory_workspace, {'api.raml': document}) + uri = f'{folder}/api.raml' + snapshot = workspace.snapshot(uri) + a = queries.type_at(snapshot, uri, *_where(document, 'A: object')) + assert a is not None + assert snapshot.semantic._children is None + assert [item.name for item in queries.subtypes(snapshot, a)] == ['Alias', 'Both', 'Last', 'Duplicate'] + children = snapshot.semantic.children + assert [item.name for item in queries.subtypes(snapshot, a)] == ['Alias', 'Both', 'Last', 'Duplicate'] + assert snapshot.semantic.children is children + both = queries.type_at(snapshot, uri, *_where(document, 'Both:')) + assert both is not None + parents = queries.supertypes(snapshot, both) + assert [item.name for item in parents] == ['B', 'A'] + assert [item.selection.line for item in parents] == [_where(document, f'{name}: object')[0] for name in ('B', 'A')] + alias = queries.type_at(snapshot, uri, *_where(document, 'Alias: A')) + assert alias is not None + assert [item.name for item in queries.subtypes(snapshot, alias)] == ['ViaAlias'] + + +def test_multiple_parent_wrappers_navigate_qualified_references_to_the_library_declarations(memory_workspace): + library = '#%RAML 1.0 Library\ntypes:\n A: object\n B: object\n' + document = '#%RAML 1.0\ntitle: T\nuses:\n lib: lib.raml\ntypes:\n Both: {type: [lib.B, lib.A]}\n' + workspace, folder = _buffered(memory_workspace, {'api.raml': document, 'lib.raml': library}) + uri = f'{folder}/api.raml' + snapshot = workspace.snapshot(uri) + assert snapshot.error is None + both = queries.type_at(snapshot, uri, *_where(document, 'Both:')) + assert both is not None + parents = queries.supertypes(snapshot, both) + assert [(item.name, item.uri) for item in parents] == [('B', f'{folder}/lib.raml'), ('A', f'{folder}/lib.raml')] + assert [item.name for item in queries.subtypes(snapshot, parents[0])] == ['Both'] + + +def test_root_edits_replace_outline_and_hierarchy_caches_but_held_snapshots_keep_their_answers(memory_workspace): + document = '#%RAML 1.0\ntitle: T\ntypes:\n A: string\n B: string\n Child: A\n' + workspace, folder = _buffered(memory_workspace, {'api.raml': document}) + uri = f'{folder}/api.raml' + old = workspace.snapshot(uri) + old_outline = outline.document_symbols(old, uri) + item = queries.type_at(old, uri, *_where(document, 'Child:')) + assert item is not None + assert [parent.name for parent in queries.supertypes(old, item)] == ['A'] + workspace.change(uri, document.replace('Child: A', 'Child: B'), 2) + new = workspace.snapshot(uri) + assert outline.document_symbols(new, uri) is not old_outline + assert [parent.name for parent in queries.supertypes(new, item)] == ['B'] + assert [parent.name for parent in queries.supertypes(old, item)] == ['A'] + assert outline.document_symbols(old, uri) is old_outline + assert new.semantic is not old.semantic + + +def test_shared_dependency_edits_invalidate_separate_root_outline_contexts(memory_workspace): + files = { + 'a.raml': '#%RAML 1.0\ntitle: A\nuses:\n lib: lib.raml\n', + 'b.raml': '#%RAML 1.0\ntitle: B\nuses:\n lib: lib.raml\n', + 'lib.raml': LIBRARY, + } + workspace, folder = _buffered(memory_workspace, files) + library = f'{folder}/lib.raml' + a, b = (workspace.snapshot(f'{folder}/{name}.raml') for name in ('a', 'b')) + outlines = [outline.document_symbols(snapshot, library) for snapshot in (a, b)] + assert outlines[0] + assert outlines[0] is not outlines[1] + assert a.semantic is not b.semantic + workspace.change(library, LIBRARY.replace('name: string', 'age: integer'), 2) + for name, previous in zip(('a', 'b'), outlines, strict=True): + snapshot = workspace.snapshot(f'{folder}/{name}.raml') + current = outline.document_symbols(snapshot, library) + assert current is not previous + assert current[0].children[0].children[0].name == 'age' + + +@pytest.mark.parametrize('document', ['#%RAML 1.0\ntitle: T\n', '#%RAML 1.0\ntitle: [\n']) +def test_empty_outline_results_are_cached_even_on_unavailable_models(memory_workspace, document): + workspace, folder = _buffered(memory_workspace, {'api.raml': document}) + snapshot = workspace.snapshot(f'{folder}/api.raml') + uri = f'{folder}/missing.raml' + first = outline.document_symbols(snapshot, uri) + assert first == [] + assert outline.document_symbols(snapshot, uri) is first diff --git a/tests/unit/test_service_probe_paths.py b/tests/unit/test_service_probe_paths.py new file mode 100644 index 00000000..4b910600 --- /dev/null +++ b/tests/unit/test_service_probe_paths.py @@ -0,0 +1,62 @@ +"""Published service measurements keep import and checkout paths relative.""" + +import json +import runpy +import sys +from pathlib import Path, PurePosixPath, PureWindowsPath +from types import SimpleNamespace + +import pytest + +import fastraml +from bench import corpus + +ROOT = Path(__file__).resolve().parents[2] +REPORTS = ROOT / 'docs/reports/2026-10-10' + + +def test_recorded_measurements_use_relative_checkout_and_import_paths(): + for name in ('service-model-inlays-phase-metrics.json', 'service-shared-indices-phase-metrics.json'): + document = json.loads((REPORTS / name).read_text(encoding='utf-8')) + assert document['trees'] + for _, tree in document['trees']: + assert not PurePosixPath(tree).is_absolute() + assert not PureWindowsPath(tree).is_absolute() + for rounds in document['results'].values(): + assert rounds + for measurement in rounds: + assert measurement['import'] == 'fastraml/__init__.py' + + +def test_phase_worker_reports_the_import_relative_to_its_verified_checkout(tmp_path, monkeypatch, capsys): + entry = corpus.write_hover(tmp_path, family_count=1) + monkeypatch.chdir(ROOT) + monkeypatch.setattr(sys, 'path', sys.path.copy()) + probe = runpy.run_path(str(REPORTS / 'service_cost_probe.py')) + probe['worker']( + SimpleNamespace( + entry=str(entry), + focus=str(entry), + repeat=1, + no_capture=False, + detail=False, + mixed=False, + ownership=False, + rss_edits=0, + source_first=False, + ) + ) + result = json.loads(capsys.readouterr().out) + assert result['import'] == 'fastraml/__init__.py' + assert str(ROOT) not in json.dumps(result) + + +def test_wrong_checkout_import_is_rejected_without_reporting_its_absolute_folder(tmp_path, monkeypatch): + monkeypatch.chdir(ROOT) + monkeypatch.setattr(sys, 'path', sys.path.copy()) + private = tmp_path / 'private-checkout/fastraml/__init__.py' + monkeypatch.setattr(fastraml, '__file__', str(private)) + probe = runpy.run_path(str(REPORTS / 'service_cost_probe.py')) + with pytest.raises(RuntimeError, match='outside the requested tree') as caught: + probe['worker'](SimpleNamespace()) + assert str(private) not in str(caught.value)