lex_vcs/operation.rs
1//! The `Operation` enum + `OperationRecord` (operation plus its
2//! causal parents and resulting `OpId`).
3//!
4//! See `lib.rs` for the design context and #129 for the issue.
5
6use indexmap::IndexSet;
7use serde::{Deserialize, Serialize};
8use std::collections::{BTreeMap, BTreeSet};
9
10use crate::canonical;
11
12/// Signature identity of a function or type — the part that stays
13/// stable across body edits. Wraps the same string identity
14/// `lex-store` uses; we keep it as `String` here so this crate has
15/// no dependency on `lex-store`'s internals.
16pub type SigId = String;
17
18/// Content hash of a single stage (function body, type def, ...).
19/// Same string identity as the file under `<root>/stages/<SigId>/
20/// implementations/<StageId>.ast.json`.
21pub type StageId = String;
22
23/// Identity of an operation. `(kind, payload, parents)` SHA-256 in
24/// lowercase hex (64 chars). Two operations with identical payloads
25/// and parent sets produce identical `OpId`s; the store dedupes on
26/// this.
27pub type OpId = String;
28
29/// Sorted set of effect-kind strings (e.g. `["fs_write", "io"]`).
30/// `BTreeSet` so the canonical form is order-independent for
31/// hashing.
32pub type EffectSet = BTreeSet<String>;
33
34/// Reference to an imported module — either a stdlib name
35/// (`std.io`) or a local path (`./helpers`). Kept as a string so
36/// this crate doesn't pull in `lex-syntax`'s parser.
37pub type ModuleRef = String;
38
39/// Content hash of a blob in the store's `blobs/` dir (#1007): lowercase
40/// hex SHA-256 of its exact bytes. A `SetFiles` op names its manifest by one.
41pub type BlobId = String;
42
43/// Whether an `AddImport`/`RemoveImport` `module` is a **local** import — a
44/// path to a sibling file in the same package (`./error`, `../shared/util`,
45/// `/abs/x`) — rather than a stdlib module (`std.io`) or a package
46/// (`lex-nt/lib`) (#909).
47///
48/// A local import is recorded in the op-log only as metadata for `export-git`
49/// (the alias the source spelled it under). The mangler has already flattened it
50/// out of the program, so it is not an import *edge* in any sense a type-check
51/// gate, dependency resolver, or head reconstruction cares about: every reader
52/// that turns a head's imports into `Stage::Import`s must skip these, or a
53/// `./error` would be handed to a resolver/loader as though it named a
54/// registry package. Mirrors the loader's own path-import test.
55pub fn is_local_import(module: &str) -> bool {
56 module.starts_with("./") || module.starts_with("../") || module.starts_with('/')
57}
58
59/// The alias a module binds to when the import writes no explicit
60/// `as` — the module reference's last path segment, splitting on
61/// either `.` (stdlib, `std.sql` → `sql`) or `/` (local/package,
62/// `./error` → `error`, `lex-web/lib` → `lib`). Lex actually requires
63/// an explicit alias on every import, so this is not a language
64/// default; it is the convention `AddImport` uses to decide when an
65/// alias can be omitted from the op (keeping the `OpId` stable) and
66/// `export-git` uses to reconstruct it. The two MUST agree, so both
67/// call this one function.
68pub fn default_import_alias(module: &str) -> String {
69 module
70 .rsplit(['.', '/'])
71 .find(|seg| !seg.is_empty())
72 .unwrap_or(module)
73 .to_string()
74}
75
76/// Version tag for the operation canonical form (#244).
77///
78/// The pre-image bytes hashed to derive an `OpId` are not stable
79/// across schema evolutions: adding a field to `OperationKind` or
80/// changing its serde representation rotates every existing `OpId`.
81/// This enum tags the encoding used so a long-lived store can detect
82/// mismatches and migrate explicitly via [`crate::migrate`].
83///
84/// **Today only [`Self::V1`] is in production.** Adding a future
85/// variant requires:
86///
87/// 1. A new arm in [`Operation::canonical_bytes_in`].
88/// 2. An update to the canonical-form spec in [`crate::canonical`].
89/// 3. A `CHANGELOG.md` entry under `### Internal` calling out the
90/// `OpId` rotation.
91/// 4. A migration recipe via [`crate::migrate::plan_migration`] —
92/// the mechanism is encoder-agnostic, but each new variant needs
93/// its own `canonical_bytes_in` arm.
94#[derive(
95 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default,
96)]
97#[serde(rename_all = "lowercase")]
98pub enum OperationFormat {
99 #[default]
100 V1,
101}
102
103impl OperationFormat {
104 /// The format every newly-emitted op uses today.
105 pub const CURRENT: OperationFormat = OperationFormat::V1;
106
107 /// `true` for the implicit format (V1). Used by the
108 /// `skip_serializing_if` hook on [`OperationRecord::format_version`]
109 /// so existing V1 stores keep byte-identical on-disk JSON —
110 /// adding the version field doesn't itself rotate any `OpId`.
111 pub fn is_implicit(&self) -> bool {
112 matches!(self, OperationFormat::V1)
113 }
114}
115
116/// Effect of applying an operation on a stage's content-addressed
117/// identity. Used as the `produces` field of an [`OperationRecord`]
118/// so consumers can answer "after this op, what's the head stage
119/// for this SigId?" without rerunning the apply step.
120#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121#[serde(tag = "kind", rename_all = "snake_case")]
122pub enum StageTransition {
123 /// New SigId; produces a stage that didn't exist before.
124 Create { sig_id: SigId, stage_id: StageId },
125 /// Existing SigId; replaces its head stage.
126 Replace { sig_id: SigId, from: StageId, to: StageId },
127 /// SigId removed; no head stage afterwards.
128 Remove { sig_id: SigId, last: StageId },
129 /// SigId renamed; same body hash, different signature identity.
130 Rename { from: SigId, to: SigId, body_stage_id: StageId },
131 /// Import-only change; doesn't touch any stage.
132 ImportOnly,
133 /// Merge op result. `entries` pins every sig the merge **decided**
134 /// (every sig either side touched since the merge base) to the value
135 /// the merge resolved it to: `Some(stage_id)` sets the head; `None`
136 /// removes the sig. Sigs neither side touched are not listed.
137 ///
138 /// Merge ops written before #1062 list only the sigs whose head
139 /// changed relative to dst, so a sig kept as dst had it (`take_ours`)
140 /// is absent and the replay order of the two parallel histories
141 /// decided it. They still load and replay — to one answer, see
142 /// `OpLog::walk_forward` — but their resolution cannot be recovered.
143 /// Note that `parents` are sorted by op id, so neither position says
144 /// which parent was dst; that is why the entries are absolute values,
145 /// not a delta.
146 ///
147 /// **Canonical-form contract:** `BTreeMap` is load-bearing —
148 /// iteration is sorted by `SigId`, so on-disk JSON for two
149 /// callers that resolved the same conflicts in different
150 /// orders produces byte-identical output. Switching to
151 /// `HashMap` here would break canonical stability of the
152 /// `OperationRecord` JSON file and is rejected by the
153 /// canonical-form spec in `crate::canonical`.
154 Merge {
155 entries: BTreeMap<SigId, Option<StageId>>,
156 },
157 /// Files-only change (#1007): a [`OperationKind::SetFiles`] op. The
158 /// sig→stage map is untouched.
159 FilesOnly,
160}
161
162impl StageTransition {
163 /// Every stage id this transition references — the content-addressed
164 /// blobs a peer needs alongside the op record to render or replay it.
165 /// Used by `op push`/`pull` to sync stage objects, not just op records.
166 pub fn stage_ids(&self) -> Vec<StageId> {
167 match self {
168 StageTransition::Create { stage_id, .. } => vec![stage_id.clone()],
169 StageTransition::Replace { from, to, .. } => vec![from.clone(), to.clone()],
170 StageTransition::Remove { last, .. } => vec![last.clone()],
171 StageTransition::Rename { body_stage_id, .. } => vec![body_stage_id.clone()],
172 StageTransition::ImportOnly | StageTransition::FilesOnly => Vec::new(),
173 StageTransition::Merge { entries } => entries.values().flatten().cloned().collect(),
174 }
175 }
176
177 /// Every `(sig_id, stage_id)` pair this transition references (#986).
178 ///
179 /// A `StageId` hashes the structural signature plus the implementation and
180 /// deliberately **not** the name (#826), so two functions differing only in
181 /// name share one StageId while having two distinct SigIds — and two
182 /// separate ASTs, one stored under each sig. Rendering therefore resolves a
183 /// stage through [`crate`]'s `(sig, stage)` pair, never the id alone.
184 ///
185 /// [`Self::stage_ids`] is consequently not enough for object sync: asking a
186 /// peer "do you have this stage id?" can answer yes while the variant the
187 /// head actually names is absent. Sync paths should use these pairs.
188 pub fn stage_pairs(&self) -> Vec<(SigId, StageId)> {
189 match self {
190 StageTransition::Create { sig_id, stage_id } => {
191 vec![(sig_id.clone(), stage_id.clone())]
192 }
193 StageTransition::Replace { sig_id, from, to } => vec![
194 (sig_id.clone(), from.clone()),
195 (sig_id.clone(), to.clone()),
196 ],
197 StageTransition::Remove { sig_id, last } => vec![(sig_id.clone(), last.clone())],
198 // Only `to` (#992). A SigId covers the declaration's identity, so
199 // the renamed body's AST hashes to the *new* sig and a store files
200 // it there and only there — `(from, body_stage_id)` is a pair no
201 // store can hold. Asking for it made the reconciler demand a blob
202 // that cannot exist and refuse an otherwise valid push. The
203 // transition agrees: `apply_transition` drops `from` and inserts
204 // `to → body_stage_id`, so the head never names `from` either.
205 StageTransition::Rename { to, body_stage_id, .. } => {
206 vec![(to.clone(), body_stage_id.clone())]
207 }
208 StageTransition::ImportOnly | StageTransition::FilesOnly => Vec::new(),
209 StageTransition::Merge { entries } => entries
210 .iter()
211 .filter_map(|(sig, stage)| stage.as_ref().map(|st| (sig.clone(), st.clone())))
212 .collect(),
213 }
214 }
215}
216
217/// The kinds of operations that produce stage transitions. Mirrors
218/// the initial set in #129; new kinds (`MoveBetweenFiles`,
219/// `SplitFunction`, `ExtractType`) can be added later as long as
220/// they're appended at the end of this enum or use explicit
221/// `#[serde(rename = "...")]` tags so existing `OpId`s stay stable.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[serde(tag = "op", rename_all = "snake_case")]
224pub enum OperationKind {
225 /// New function published. `effects` is the effect set declared
226 /// in the signature; tracked here (not just inside the stage)
227 /// so #130's write-time gate has a cheap path to check effect
228 /// changes without rehydrating the AST.
229 ///
230 /// `budget_cost` (#247) records the function's declared
231 /// `[budget(N)]` cost. Optional with `skip_serializing_if`, so
232 /// pre-#247 ops without a declared budget continue to hash to
233 /// their original `OpId` (additive serialization, same trick
234 /// `intent_id` uses). `None` means the function declared no
235 /// budget effect; `Some(n)` is the literal `n` from
236 /// `[budget(n)]`.
237 AddFunction {
238 sig_id: SigId,
239 stage_id: StageId,
240 effects: EffectSet,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
242 budget_cost: Option<u64>,
243 /// The package source file this declaration came from
244 /// (`src/schema.lex`), when published as part of a multi-module
245 /// package. `None` for a single-file publish — so those ops
246 /// serialize byte-identically and keep their `OpId` (same
247 /// additive trick as `budget_cost`). Lets `export-git` de-flatten
248 /// a mangled package back into its `src/*.lex` tree (#894) and
249 /// gives `lex blame` per-file provenance.
250 #[serde(default, skip_serializing_if = "Option::is_none")]
251 in_file: Option<String>,
252 },
253 /// Function removed; `last_stage_id` is the head before the
254 /// remove (so blame can walk the predecessor without scanning).
255 RemoveFunction {
256 sig_id: SigId,
257 last_stage_id: StageId,
258 },
259 /// Function body changed; signature unchanged.
260 ///
261 /// `from_budget` / `to_budget` (#247) record the declared
262 /// `[budget(N)]` on each side. Same `Option` + `skip` discipline
263 /// as `AddFunction.budget_cost` — pre-#247 ops keep their
264 /// `OpId`s. The pair is what `lex op log --budget-drift` reads
265 /// to surface "budget grew/shrank" diffs without rehydrating
266 /// stages.
267 ModifyBody {
268 sig_id: SigId,
269 from_stage_id: StageId,
270 to_stage_id: StageId,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 from_budget: Option<u64>,
273 #[serde(default, skip_serializing_if = "Option::is_none")]
274 to_budget: Option<u64>,
275 /// The SigId the declaration moves **to**, when the modification
276 /// changed the signature itself (#992) — same field, same reason, as
277 /// [`OperationKind::ChangeEffectSig::to_sig_id`].
278 ///
279 /// A SigId covers the input/output types and the signature-level
280 /// `examples`, not just the name, so a same-name edit to any of those
281 /// is a new sig. The publish diff keys declarations by name and read
282 /// it as a plain modification, so the head kept the *old* sig bound to
283 /// the *new* stage: a pair no store can hold. `None` for a body-only
284 /// change — and for every op written before this field — keeping the
285 /// in-place `Replace` and byte-identical OpIds.
286 #[serde(default, skip_serializing_if = "Option::is_none")]
287 to_sig_id: Option<SigId>,
288 },
289 /// Symbol renamed. The body hash is preserved (`body_stage_id`)
290 /// so two renames of the same body collapse to the same OpId
291 /// and `lex blame` walks the rename as a single causal event
292 /// rather than `delete + add`.
293 RenameSymbol {
294 from: SigId,
295 to: SigId,
296 body_stage_id: StageId,
297 /// The package source file the declaration lives in **after** the
298 /// rename, recorded only when that differs from where it was (#1060).
299 ///
300 /// A multi-module package mangles each declaration with a prefix
301 /// derived from its file's path, so moving `src/util.lex` to
302 /// `src/lib/util.lex` renames every declaration in it. The rename
303 /// leaves the body alone, so without this the head kept the *old*
304 /// `in_file` and `export-git` rendered the moved declaration back
305 /// into the old file. `None` for an in-place rename — and for every op
306 /// written before this field — so those keep their `OpId`.
307 #[serde(default, skip_serializing_if = "Option::is_none")]
308 in_file: Option<String>,
309 },
310 /// Effect signature changed. Captures both old and new effect
311 /// sets so the write-time gate (#130) can verify importers
312 /// haven't silently broken.
313 ///
314 /// `from_budget` / `to_budget` (#247) capture the declared
315 /// `[budget(N)]` on each side. ChangeEffectSig usually fires
316 /// because the effect *list* changed; #247 makes budget drift
317 /// visible without forcing a full effect-set diff.
318 ChangeEffectSig {
319 sig_id: SigId,
320 from_stage_id: StageId,
321 to_stage_id: StageId,
322 from_effects: EffectSet,
323 to_effects: EffectSet,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
325 from_budget: Option<u64>,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 to_budget: Option<u64>,
328 /// The SigId the declaration moves **to** (#992).
329 ///
330 /// A SigId covers the effect row, so changing a function's effects
331 /// changes its sig — the op's own name says as much. Without this the
332 /// transition was a `Replace`, which keeps the *old* sig pointing at
333 /// the *new* stage. But a store files an implementation under the sig
334 /// its own AST hashes to, and that AST declares the new effects, so
335 /// the head entry was unsatisfiable by construction: no store could
336 /// ever hold `(old_sig, new_stage)`. The head then could not be
337 /// rendered, and any release cut from it was born broken —
338 /// `lex-web@0.4.0` is exactly that.
339 ///
340 /// `None` is how every op written before this field existed decodes,
341 /// and it keeps their original `Replace` behaviour so historical logs
342 /// replay unchanged. `skip_serializing_if` keeps those ops
343 /// byte-identical, so their OpIds do not move.
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 to_sig_id: Option<SigId>,
346 },
347 /// Import added to a file. `in_file` is the canonical path
348 /// (relative to the repo root, forward-slashes) so two
349 /// machines hashing the same edit get the same OpId.
350 AddImport {
351 in_file: String,
352 module: ModuleRef,
353 /// The binding the module is imported under (`import "std.sql"
354 /// as sql` → `"sql"`). Lex requires an alias on every import,
355 /// but this is `None` whenever it equals the module's default
356 /// alias (the last path segment) — the common case — so those
357 /// `AddImport`s serialize exactly as before and keep their
358 /// original `OpId` (additive serialization, same trick as
359 /// `budget_cost`). Only a non-default alias (`import "./error"
360 /// as e`) is carried explicitly. Without it, `export-git`
361 /// cannot reconstruct a compilable module — the reference alone
362 /// doesn't say what name the body binds.
363 #[serde(default, skip_serializing_if = "Option::is_none")]
364 alias: Option<String>,
365 },
366 RemoveImport {
367 in_file: String,
368 module: ModuleRef,
369 },
370 AddType {
371 sig_id: SigId,
372 stage_id: StageId,
373 /// Source file this type came from, for multi-module packages;
374 /// `None` (and omitted) for a single-file publish. See
375 /// `AddFunction::in_file`.
376 #[serde(default, skip_serializing_if = "Option::is_none")]
377 in_file: Option<String>,
378 },
379 RemoveType {
380 sig_id: SigId,
381 last_stage_id: StageId,
382 },
383 ModifyType {
384 sig_id: SigId,
385 from_stage_id: StageId,
386 to_stage_id: StageId,
387 /// The SigId the type moves **to** when its params changed (#992).
388 /// See [`OperationKind::ModifyBody::to_sig_id`].
389 #[serde(default, skip_serializing_if = "Option::is_none")]
390 to_sig_id: Option<SigId>,
391 },
392 /// Merge of two branch heads. Carries only an informational count
393 /// of resolved sigs so two structurally identical merges of
394 /// different sizes don't collide on op_id; the per-sig deltas live
395 /// in `OperationRecord::produces` (`StageTransition::Merge`).
396 Merge {
397 resolved: usize,
398 },
399 /// Typed transform: inlined a `let x := v; body` by
400 /// substituting `v` for every unshadowed `x` in `body`, then
401 /// replacing the entire `Let` node with the substituted body
402 /// (#280). The op records the let-binding's position and the
403 /// inlined name; the actual substituted value lives in the
404 /// content-addressed `to_stage_id` so the op_id stays compact.
405 InlineLet {
406 sig_id: SigId,
407 from_stage_id: StageId,
408 to_stage_id: StageId,
409 let_node: String,
410 binding_name: String,
411 #[serde(default, skip_serializing_if = "Option::is_none")]
412 from_budget: Option<u64>,
413 #[serde(default, skip_serializing_if = "Option::is_none")]
414 to_budget: Option<u64>,
415 },
416 /// Typed transform: renamed a `let`-bound local within a fn
417 /// body (#280). Records the old/new identifiers and the position
418 /// of the let-binding in the AST. Body-shape-stable: the renamed
419 /// stage typically hashes near the original.
420 RenameLocal {
421 sig_id: SigId,
422 from_stage_id: StageId,
423 to_stage_id: StageId,
424 /// Path-style NodeId of the `Let` expression at the time of
425 /// the transform.
426 let_node: String,
427 old_name: String,
428 new_name: String,
429 #[serde(default, skip_serializing_if = "Option::is_none")]
430 from_budget: Option<u64>,
431 #[serde(default, skip_serializing_if = "Option::is_none")]
432 to_budget: Option<u64>,
433 },
434 /// Typed transform: replaced one arm's body in a `Match`
435 /// expression (#280). Semantically a `ModifyBody`, but the op
436 /// records *which* arm changed and *where* in the AST — so the
437 /// op log reads as a semantic edit history rather than as
438 /// opaque hash-to-hash bytes.
439 ///
440 /// `match_node` is the [`lex_ast::ids::NodeId`] of the Match
441 /// expression at the time of the transform. NodeIds aren't
442 /// stable across structural edits — they're audit-trail metadata,
443 /// not re-derivation keys. The authoritative record of the new
444 /// stage is `to_stage_id` (content-addressed).
445 ///
446 /// `from_budget`/`to_budget` follow the same `skip_if_none`
447 /// discipline as [`Self::ModifyBody`]: pre-#280 ops continue
448 /// hashing to their original `OpId`s.
449 ReplaceMatchArm {
450 sig_id: SigId,
451 from_stage_id: StageId,
452 to_stage_id: StageId,
453 /// Path-style NodeId of the Match expression that was
454 /// modified, captured at transform time. See
455 /// [`lex_ast::ids::NodeId`] for the format.
456 match_node: String,
457 arm_index: usize,
458 #[serde(default, skip_serializing_if = "Option::is_none")]
459 from_budget: Option<u64>,
460 #[serde(default, skip_serializing_if = "Option::is_none")]
461 to_budget: Option<u64>,
462 },
463 /// Multi-agent coordination: a stage proposed for a sig
464 /// without advancing the branch (#294). Multiple agents can
465 /// land `Candidate` ops on the same sig concurrently without
466 /// contention — they all chain off the current head and don't
467 /// move it. Used together with [`Self::Promote`] to model
468 /// bake-offs: several agents propose, one is promoted.
469 ///
470 /// The `Operation`'s `intent_id` is expected to be set so
471 /// downstream consumers can distinguish proposals by author.
472 /// (The schema doesn't enforce this; the gate does.)
473 Candidate {
474 sig_id: SigId,
475 stage_id: StageId,
476 },
477 /// Multi-agent coordination: promotes a previously-landed
478 /// [`Self::Candidate`] op as the new head for its sig (#294).
479 /// Carries the list of *other* candidates this Promote
480 /// supersedes so the op log explicitly records the bake-off
481 /// shape.
482 ///
483 /// Acts as a `ModifyBody` (or `AddFunction` when the sig has
484 /// no head) for branch-head purposes — `transition_for_kind`
485 /// returns the appropriate `StageTransition`.
486 Promote {
487 sig_id: SigId,
488 /// Op id of the [`Self::Candidate`] being promoted.
489 winner_candidate: OpId,
490 /// Stage id of the winner (duplicates the candidate's
491 /// `stage_id` for fast lookup; saves a log round-trip
492 /// for `lex op show`).
493 winner_stage_id: StageId,
494 /// Every other live `Candidate` for `sig_id` at the time
495 /// of promotion. Sorted by op_id for canonical-form
496 /// stability. After this `Promote` lands, none of these
497 /// op_ids appear in [`Store::list_candidates`].
498 supersedes: Vec<OpId>,
499 /// Current branch head stage for `sig_id`, or `None` if
500 /// the sig had no head (the Promote is creating it).
501 /// `None` is serialized as missing for canonical stability
502 /// across "first promote on a sig" vs "later promote".
503 #[serde(default, skip_serializing_if = "Option::is_none")]
504 from_stage_id: Option<StageId>,
505 #[serde(default, skip_serializing_if = "Option::is_none")]
506 from_budget: Option<u64>,
507 #[serde(default, skip_serializing_if = "Option::is_none")]
508 to_budget: Option<u64>,
509 },
510 /// Non-semantic (#1007): set the repository's non-op-log files —
511 /// README, `lex.toml`, `tests/`, CI config — to the full snapshot named
512 /// by `manifest` (a blob holding a canonical files manifest, like a git
513 /// tree). Recorded, ordered and content-addressed with the rest of the
514 /// history, but never replayed, type-checked or gated as code; the
515 /// sig→stage map is untouched ([`StageTransition::FilesOnly`]).
516 SetFiles { manifest: BlobId },
517}
518
519impl OperationKind {
520 /// Whether this op changes the program (#1007). `false` only for
521 /// [`Self::SetFiles`], which replay, gates and replay coverage skip by
522 /// kind: a file snapshot has no stage to regenerate or type-check.
523 pub fn is_semantic(&self) -> bool {
524 !matches!(self, OperationKind::SetFiles { .. })
525 }
526
527 /// The `(SigId, Option<StageId>)` an op kind targets, as used by
528 /// `StageTransition::Merge::entries`. Used by the merge-commit
529 /// path (#134) to translate a `Resolution::Custom { op }` into
530 /// the head-map delta the merge op records:
531 ///
532 /// * Adds → `(sig, Some(stage_id))`
533 /// * Modifies → `(sig, Some(to_stage_id))`
534 /// * Removes → `(sig, None)`
535 /// * Renames → `(to_sig, Some(body_stage_id))`
536 /// * `AddImport` / `RemoveImport` / nested `Merge` → `None`
537 /// (no single sig→stage delta)
538 pub fn merge_target(&self) -> Option<(SigId, Option<StageId>)> {
539 use OperationKind::*;
540 match self {
541 AddFunction { sig_id, stage_id, .. }
542 | AddType { sig_id, stage_id, .. }
543 => Some((sig_id.clone(), Some(stage_id.clone()))),
544 // #992: a sig-moving modification lands under the sig it moves to.
545 ModifyBody { sig_id, to_stage_id, to_sig_id, .. }
546 | ChangeEffectSig { sig_id, to_stage_id, to_sig_id, .. }
547 | ModifyType { sig_id, to_stage_id, to_sig_id, .. }
548 => Some((to_sig_id.as_ref().unwrap_or(sig_id).clone(), Some(to_stage_id.clone()))),
549 ReplaceMatchArm { sig_id, to_stage_id, .. }
550 | RenameLocal { sig_id, to_stage_id, .. }
551 | InlineLet { sig_id, to_stage_id, .. }
552 => Some((sig_id.clone(), Some(to_stage_id.clone()))),
553 Promote { sig_id, winner_stage_id, .. }
554 => Some((sig_id.clone(), Some(winner_stage_id.clone()))),
555 RemoveFunction { sig_id, .. }
556 | RemoveType { sig_id, .. }
557 => Some((sig_id.clone(), None)),
558 RenameSymbol { to, body_stage_id, .. }
559 => Some((to.clone(), Some(body_stage_id.clone()))),
560 AddImport { .. } | RemoveImport { .. } | Merge { .. } | SetFiles { .. } => None,
561 // Candidate ops don't advance the branch head; they
562 // don't fit the (sig, Option<stage_id>) head-delta
563 // shape that `merge_target` describes.
564 Candidate { .. } => None,
565 }
566 }
567
568 /// `(from_budget, to_budget)` for ops that carry a budget delta
569 /// (#247). `(None, None)` for ops where the budget isn't part
570 /// of the canonical payload — `RemoveFunction`, `RenameSymbol`,
571 /// imports, and merges. `AddFunction` reports `(None,
572 /// Some(cost))` for "this is the initial cost." Used by `lex op
573 /// show`, `lex op log --budget-drift`, and `lex audit --budget`.
574 pub fn budget_delta(&self) -> (Option<u64>, Option<u64>) {
575 use OperationKind::*;
576 match self {
577 AddFunction { budget_cost, .. } => (None, *budget_cost),
578 ModifyBody { from_budget, to_budget, .. }
579 | ChangeEffectSig { from_budget, to_budget, .. }
580 | ReplaceMatchArm { from_budget, to_budget, .. }
581 | RenameLocal { from_budget, to_budget, .. }
582 | InlineLet { from_budget, to_budget, .. }
583 | Promote { from_budget, to_budget, .. } => (*from_budget, *to_budget),
584 _ => (None, None),
585 }
586 }
587
588 /// The `SigId` an op touches if it carries a budget — used for
589 /// per-sig audit rollups in `lex audit --budget`. Returns `None`
590 /// for ops without a relevant budget (the same set as the
591 /// `_ => (None, None)` arm of [`Self::budget_delta`]).
592 pub fn budget_sig(&self) -> Option<&SigId> {
593 use OperationKind::*;
594 match self {
595 AddFunction { sig_id, .. }
596 | ModifyBody { sig_id, .. }
597 | ChangeEffectSig { sig_id, .. }
598 | ReplaceMatchArm { sig_id, .. }
599 | RenameLocal { sig_id, .. }
600 | InlineLet { sig_id, .. }
601 | Promote { sig_id, .. } => Some(sig_id),
602 _ => None,
603 }
604 }
605}
606
607/// Extract the declared `[budget(N)]` integer from an [`EffectSet`],
608/// if any (#247).
609///
610/// Effect labels in [`EffectSet`] are produced by
611/// [`crate::compute_diff::effect_label`]: a `[budget(50)]`
612/// declaration becomes the literal string `"budget(50)"`. This
613/// helper parses that literal back to the integer; bare `"budget"`
614/// (no arg) returns `None` because the magnitude is unknown. A
615/// stage with multiple budget declarations — which the type-
616/// checker should reject anyway — picks the smallest, conservative
617/// answer for `lex audit --budget`.
618pub fn budget_from_effects(effects: &EffectSet) -> Option<u64> {
619 let mut min_cost: Option<u64> = None;
620 for label in effects {
621 let Some(rest) = label.strip_prefix("budget(") else { continue };
622 let Some(inner) = rest.strip_suffix(')') else { continue };
623 let Ok(n) = inner.parse::<u64>() else { continue };
624 min_cost = Some(min_cost.map(|c| c.min(n)).unwrap_or(n));
625 }
626 min_cost
627}
628
629/// The operation as a whole — its kind and the causal predecessors
630/// it assumes. The `OpId` is computed from this plus a sorted view
631/// of `parents`.
632///
633/// Operations without parents are valid and represent "applies to
634/// the empty repository" or "applies to the synthetic genesis
635/// state." `lex store migrate v1→v2` will produce parentless ops
636/// for stages it can't trace back to a clear predecessor.
637#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
638pub struct Operation {
639 #[serde(flatten)]
640 pub kind: OperationKind,
641 /// Operations whose `produces` this op assumes. Sorted before
642 /// hashing for canonical form. Empty for ops against the empty
643 /// repo.
644 #[serde(default, skip_serializing_if = "Vec::is_empty")]
645 pub parents: Vec<OpId>,
646 /// The intent that caused this op, if known. Optional because
647 /// operations produced outside an agent harness (e.g. a human
648 /// running `lex publish` directly) don't have one.
649 ///
650 /// Including the intent in the canonical hash means the same
651 /// logical change made under different intents produces
652 /// different `OpId`s — causally distinct events should hash
653 /// distinctly. Ops with `intent_id: None` keep their existing
654 /// hashes (the field is omitted from the canonical JSON via
655 /// `skip_serializing_if`), so this is backwards-compatible
656 /// for stores written before #131.
657 #[serde(default, skip_serializing_if = "Option::is_none")]
658 pub intent_id: Option<crate::intent::IntentId>,
659}
660
661impl Operation {
662 /// Construct an operation against zero or more parents. Caller
663 /// supplies parents in any order; canonicalization sorts them
664 /// before hashing.
665 pub fn new(kind: OperationKind, parents: impl IntoIterator<Item = OpId>) -> Self {
666 let mut parents: Vec<OpId> = parents.into_iter().collect();
667 parents.sort();
668 parents.dedup();
669 Self { kind, parents, intent_id: None }
670 }
671
672 /// Tag this operation with the intent that produced it. The
673 /// builder shape keeps existing call sites untouched; agent
674 /// harnesses that record intent call this once before
675 /// applying the op.
676 pub fn with_intent(mut self, intent_id: impl Into<crate::intent::IntentId>) -> Self {
677 self.intent_id = Some(intent_id.into());
678 self
679 }
680
681 /// Compute this operation's content-addressed identity under the
682 /// current production canonical form ([`OperationFormat::CURRENT`]).
683 ///
684 /// Stable across runs and machines: same `(kind, payload,
685 /// sorted parents, intent_id)` produces the same `OpId`. The
686 /// invariant #129's automatic-dedup behavior relies on.
687 pub fn op_id(&self) -> OpId {
688 self.op_id_in(OperationFormat::CURRENT)
689 }
690
691 /// Compute the `OpId` under a specific canonical-form version.
692 ///
693 /// Used by [`crate::migrate`] to derive new `OpId`s when porting
694 /// a store across format versions. Production code should call
695 /// [`Self::op_id`].
696 pub fn op_id_in(&self, format: OperationFormat) -> OpId {
697 canonical::hash_bytes(&self.canonical_bytes_in(format))
698 }
699
700 /// The byte sequence that gets hashed to produce [`Self::op_id`]
701 /// under the current canonical form. Equivalent to
702 /// `self.canonical_bytes_in(OperationFormat::CURRENT)`.
703 ///
704 /// Exposed (not just consumed by `op_id`) so golden tests can pin
705 /// the exact pre-image. **Not** equal to `serde_json::to_vec(&op)`
706 /// in general — the on-disk JSON skips empty `parents` and
707 /// `None` `intent_id`, while the canonical form always emits a
708 /// (sorted, deduped) `parents` array. See `canonical.rs` for the
709 /// full V1 canonical-form spec.
710 pub fn canonical_bytes(&self) -> Vec<u8> {
711 self.canonical_bytes_in(OperationFormat::CURRENT)
712 }
713
714 /// The pre-image hashed under a specific canonical-form version.
715 ///
716 /// Today every `OperationFormat` variant routes to V1's encoder
717 /// (only V1 exists in production). When V2 lands, this match
718 /// gains an arm and the migration tool's encoder closure routes
719 /// here.
720 pub fn canonical_bytes_in(&self, format: OperationFormat) -> Vec<u8> {
721 match format {
722 OperationFormat::V1 => self.canonical_bytes_v1(),
723 }
724 }
725
726 fn canonical_bytes_v1(&self) -> Vec<u8> {
727 // Build a transient hashable view rather than hashing
728 // `self` directly so the parent ordering is canonical
729 // even if a caller hand-constructs an `Operation` with
730 // unsorted parents.
731 let canonical = CanonicalView {
732 kind: &self.kind,
733 parents: self.parents.iter().collect::<IndexSet<_>>().into_iter().collect::<BTreeSet<_>>(),
734 intent_id: self.intent_id.as_deref(),
735 };
736 serde_json::to_vec(&canonical).expect("canonical serialization")
737 }
738}
739
740/// Hashable shadow of [`Operation`] with parents in a `BTreeSet` so
741/// the serialization is order-independent regardless of how the
742/// caller constructed the live operation. Never persisted; lives
743/// only as a transient for hashing.
744#[derive(Serialize)]
745struct CanonicalView<'a> {
746 #[serde(flatten)]
747 kind: &'a OperationKind,
748 parents: BTreeSet<&'a OpId>,
749 /// `skip_serializing_if = "Option::is_none"` keeps existing
750 /// `OpId`s stable for ops without an intent — the field is
751 /// omitted from the canonical JSON entirely.
752 #[serde(skip_serializing_if = "Option::is_none")]
753 intent_id: Option<&'a str>,
754}
755
756/// An operation paired with its computed `OpId` and the resulting
757/// stage transition. This is what gets persisted under
758/// `<root>/ops/<OpId>.json`.
759///
760/// `format_version` records the canonical form the `op_id` was
761/// computed under. Pre-#244 stores didn't emit this field; reading
762/// such records deserializes to [`OperationFormat::V1`] (the
763/// implicit pre-versioning format), and writing V1 records continues
764/// to omit it (`skip_serializing_if = is_implicit`) so adding the
765/// field doesn't rotate any existing `OpId` or change any on-disk
766/// byte. Records written under a future format will explicitly
767/// carry their version tag.
768#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
769pub struct OperationRecord {
770 pub op_id: OpId,
771 #[serde(default, skip_serializing_if = "OperationFormat::is_implicit")]
772 pub format_version: OperationFormat,
773 #[serde(flatten)]
774 pub op: Operation,
775 pub produces: StageTransition,
776}
777
778impl OperationRecord {
779 pub fn new(op: Operation, produces: StageTransition) -> Self {
780 let op_id = op.op_id();
781 Self { op_id, format_version: OperationFormat::CURRENT, op, produces }
782 }
783}
784
785#[cfg(test)]
786mod tests {
787 use super::*;
788
789 #[test]
790 fn local_imports_are_told_from_stdlib_and_packages() {
791 for local in ["./error", "../shared/util", "/abs/mod", "./util/strings"] {
792 assert!(is_local_import(local), "{local}");
793 }
794 for other in ["std.io", "lex-nt/lib", "lex-nt", "lonely"] {
795 assert!(!is_local_import(other), "{other}");
796 }
797 }
798
799 fn add_factorial() -> OperationKind {
800 OperationKind::AddFunction {
801 sig_id: "fac::Int->Int".into(),
802 stage_id: "abc123".into(),
803 effects: BTreeSet::new(),
804 budget_cost: None,
805 in_file: None,
806 }
807 }
808
809 #[test]
810 fn identical_operations_have_identical_op_ids() {
811 let a = Operation::new(add_factorial(), []);
812 let b = Operation::new(add_factorial(), []);
813 assert_eq!(a.op_id(), b.op_id());
814 }
815
816 #[test]
817 fn different_operations_have_different_op_ids() {
818 let a = Operation::new(add_factorial(), []);
819 let b = Operation::new(
820 OperationKind::AddFunction {
821 sig_id: "double::Int->Int".into(),
822 stage_id: "abc123".into(),
823 effects: BTreeSet::new(),
824 budget_cost: None,
825 in_file: None,
826 },
827 [],
828 );
829 assert_ne!(a.op_id(), b.op_id());
830 }
831
832 #[test]
833 fn parent_set_changes_op_id() {
834 let no_parent = Operation::new(add_factorial(), []);
835 let with_parent = Operation::new(add_factorial(), ["op-parent-1".into()]);
836 assert_ne!(no_parent.op_id(), with_parent.op_id());
837 }
838
839 #[test]
840 fn parent_order_does_not_affect_op_id() {
841 let a = Operation::new(add_factorial(), ["b".into(), "a".into(), "c".into()]);
842 let b = Operation::new(add_factorial(), ["c".into(), "a".into(), "b".into()]);
843 assert_eq!(a.op_id(), b.op_id());
844 // and the stored form is sorted.
845 assert_eq!(a.parents, vec!["a".to_string(), "b".to_string(), "c".to_string()]);
846 }
847
848 #[test]
849 fn duplicate_parents_are_deduped() {
850 let with_dups = Operation::new(
851 add_factorial(),
852 ["a".into(), "a".into(), "b".into()],
853 );
854 let no_dups = Operation::new(
855 add_factorial(),
856 ["a".into(), "b".into()],
857 );
858 assert_eq!(with_dups.op_id(), no_dups.op_id());
859 assert_eq!(with_dups.parents, vec!["a".to_string(), "b".to_string()]);
860 }
861
862 #[test]
863 fn rename_with_same_body_hashes_equal_across_runs() {
864 // Two independent runs producing the same rename against the
865 // same parent should produce the same OpId — this is the
866 // automatic-dedup property #129 relies on for distributed
867 // agents.
868 let kind = OperationKind::RenameSymbol {
869 from: "parse::Str->Int".into(),
870 to: "parse_int::Str->Int".into(),
871 body_stage_id: "abc123".into(),
872 in_file: None,
873 };
874 let a = Operation::new(kind.clone(), ["op-parent".into()]);
875 let b = Operation::new(kind, ["op-parent".into()]);
876 assert_eq!(a.op_id(), b.op_id());
877 }
878
879 /// #1060: `in_file` is additive. A rename without it serializes and hashes
880 /// exactly as it did before the field existed, and a record written by an
881 /// older client (no key) still loads.
882 #[test]
883 fn rename_in_file_is_additive_and_defaults_to_none() {
884 let old_json = serde_json::json!({
885 "op": "rename_symbol",
886 "from": "parse::Str->Int",
887 "to": "parse_int::Str->Int",
888 "body_stage_id": "abc123",
889 });
890 let kind: OperationKind = serde_json::from_value(old_json.clone()).expect("pre-#1060 record loads");
891 assert!(matches!(&kind, OperationKind::RenameSymbol { in_file: None, .. }));
892 assert_eq!(serde_json::to_value(&kind).unwrap(), old_json, "None adds no key");
893
894 let moved = OperationKind::RenameSymbol {
895 from: "parse::Str->Int".into(),
896 to: "parse_int::Str->Int".into(),
897 body_stage_id: "abc123".into(),
898 in_file: Some("src/lib/parse.lex".into()),
899 };
900 let with = Operation::new(moved.clone(), ["op-parent".into()]).op_id();
901 let without = Operation::new(kind, ["op-parent".into()]).op_id();
902 assert_ne!(with, without, "the destination file is part of the op's identity");
903 let round: OperationKind = serde_json::from_value(serde_json::to_value(&moved).unwrap()).unwrap();
904 assert_eq!(round, moved);
905 }
906
907 #[test]
908 fn rename_does_not_collide_with_delete_plus_add() {
909 // The whole point of `RenameSymbol` is that it's a different
910 // OpId from the (semantically-equivalent) `RemoveFunction +
911 // AddFunction` pair. Causal history sees one event, not two.
912 let rename = Operation::new(
913 OperationKind::RenameSymbol {
914 from: "parse::Str->Int".into(),
915 to: "parse_int::Str->Int".into(),
916 body_stage_id: "abc123".into(),
917 in_file: None,
918 },
919 ["op-parent".into()],
920 );
921 let remove = Operation::new(
922 OperationKind::RemoveFunction {
923 sig_id: "parse::Str->Int".into(),
924 last_stage_id: "abc123".into(),
925 },
926 ["op-parent".into()],
927 );
928 let add = Operation::new(
929 OperationKind::AddFunction {
930 sig_id: "parse_int::Str->Int".into(),
931 stage_id: "abc123".into(),
932 effects: BTreeSet::new(),
933 budget_cost: None,
934 in_file: None,
935 },
936 ["op-parent".into()],
937 );
938 assert_ne!(rename.op_id(), remove.op_id());
939 assert_ne!(rename.op_id(), add.op_id());
940 }
941
942 #[test]
943 fn effect_set_order_does_not_affect_op_id() {
944 // Effects are a BTreeSet so iteration is sorted. Build two
945 // ops via different insertion orders and confirm the
946 // canonical form is identical.
947 let a_effects: EffectSet = ["io".into(), "fs_write".into()].into_iter().collect();
948 let b_effects: EffectSet = ["fs_write".into(), "io".into()].into_iter().collect();
949 let a = Operation::new(
950 OperationKind::AddFunction {
951 sig_id: "x".into(), stage_id: "s".into(), effects: a_effects,
952 budget_cost: None,
953 in_file: None,
954 },
955 [],
956 );
957 let b = Operation::new(
958 OperationKind::AddFunction {
959 sig_id: "x".into(), stage_id: "s".into(), effects: b_effects,
960 budget_cost: None,
961 in_file: None,
962 },
963 [],
964 );
965 assert_eq!(a.op_id(), b.op_id());
966 }
967
968 #[test]
969 fn op_id_is_64_char_lowercase_hex() {
970 let id = Operation::new(add_factorial(), []).op_id();
971 assert_eq!(id.len(), 64);
972 assert!(id.chars().all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c)));
973 }
974
975 #[test]
976 fn round_trip_through_serde_json() {
977 let op = Operation::new(
978 OperationKind::ChangeEffectSig {
979 sig_id: "f".into(),
980 from_stage_id: "old".into(),
981 to_stage_id: "new".into(),
982 from_effects: BTreeSet::new(),
983 to_effects: ["io".into()].into_iter().collect(),
984 from_budget: None,
985 to_budget: None,
986 to_sig_id: None,
987 },
988 ["op-parent".into()],
989 );
990 let json = serde_json::to_string(&op).expect("serialize");
991 let back: Operation = serde_json::from_str(&json).expect("deserialize");
992 assert_eq!(op, back);
993 assert_eq!(op.op_id(), back.op_id());
994 }
995
996 #[test]
997 fn operation_record_carries_op_id() {
998 let op = Operation::new(add_factorial(), []);
999 let expected = op.op_id();
1000 let rec = OperationRecord::new(
1001 op,
1002 StageTransition::Create {
1003 sig_id: "fac::Int->Int".into(),
1004 stage_id: "abc123".into(),
1005 },
1006 );
1007 assert_eq!(rec.op_id, expected);
1008 }
1009
1010 #[test]
1011 fn intent_id_is_part_of_op_id_canonical_hash() {
1012 // The dedup property: same `(kind, parents, intent_id)`
1013 // produces the same OpId. Different intent_ids on
1014 // otherwise-identical ops produce different OpIds, so
1015 // causally distinct events (different prompts) hash
1016 // distinctly.
1017 let no_intent = Operation::new(add_factorial(), []);
1018 let with_intent_a = Operation::new(add_factorial(), [])
1019 .with_intent("intent-a");
1020 let with_intent_b = Operation::new(add_factorial(), [])
1021 .with_intent("intent-b");
1022 let with_intent_a_again = Operation::new(add_factorial(), [])
1023 .with_intent("intent-a");
1024
1025 // No-intent op is distinct from any intent-tagged variant.
1026 assert_ne!(no_intent.op_id(), with_intent_a.op_id());
1027 // Different intents → different OpIds.
1028 assert_ne!(with_intent_a.op_id(), with_intent_b.op_id());
1029 // Same intent → same OpId (the load-bearing dedup invariant).
1030 assert_eq!(with_intent_a.op_id(), with_intent_a_again.op_id());
1031 }
1032
1033 #[test]
1034 fn op_without_intent_keeps_pre_intent_op_id() {
1035 // Backwards-compat invariant: an op constructed without an
1036 // intent must hash to the same value as it would have
1037 // before #131 added the field. The golden test below pins
1038 // the exact hash; this one asserts that adding then
1039 // resetting to None doesn't drift.
1040 let mut op = Operation::new(add_factorial(), []);
1041 let baseline = op.op_id();
1042 op.intent_id = Some("transient".into());
1043 let with_intent = op.op_id();
1044 assert_ne!(baseline, with_intent);
1045 op.intent_id = None;
1046 let back = op.op_id();
1047 assert_eq!(baseline, back);
1048 }
1049
1050 /// Golden hash. If this changes, the canonical form has shifted
1051 /// and *every* op_id in every existing store has changed too —
1052 /// that's a major-version event for the data model and should
1053 /// be a deliberate decision, not an accident from reordering
1054 /// fields. Update with care.
1055 #[test]
1056 fn canonical_form_is_stable_for_a_known_input() {
1057 let op = Operation::new(
1058 OperationKind::AddFunction {
1059 sig_id: "fac::Int->Int".into(),
1060 stage_id: "abc123".into(),
1061 effects: BTreeSet::new(),
1062 budget_cost: None,
1063 in_file: None,
1064 },
1065 [],
1066 );
1067 assert_eq!(
1068 op.op_id(),
1069 "f112990d31ef2a63f3e5ca5680637ed36a54bc7e8230510ae0c0e93fcb39d104"
1070 );
1071 }
1072
1073 #[test]
1074 fn merge_kind_round_trips() {
1075 let op = Operation::new(
1076 OperationKind::Merge { resolved: 3 },
1077 ["op-a".into(), "op-b".into()],
1078 );
1079 let json = serde_json::to_string(&op).expect("ser");
1080 let back: Operation = serde_json::from_str(&json).expect("de");
1081 assert_eq!(op, back);
1082 assert_eq!(op.op_id(), back.op_id());
1083 }
1084
1085 #[test]
1086 fn merge_stage_transition_round_trips() {
1087 let mut entries = BTreeMap::new();
1088 entries.insert("sig-a".to_string(), Some("stage-a".to_string()));
1089 entries.insert("sig-b".to_string(), None); // removed by merge
1090 let t = StageTransition::Merge { entries };
1091 let json = serde_json::to_string(&t).expect("ser");
1092 let back: StageTransition = serde_json::from_str(&json).expect("de");
1093 assert_eq!(t, back);
1094 }
1095
1096 #[test]
1097 fn merge_resolved_count_changes_op_id() {
1098 // Two merges with the same parents but different resolved counts
1099 // must hash differently — keeps structurally distinct merges from
1100 // colliding on op_id.
1101 let parents: Vec<OpId> = vec!["op-a".into(), "op-b".into()];
1102 let one = Operation::new(OperationKind::Merge { resolved: 1 }, parents.clone());
1103 let two = Operation::new(OperationKind::Merge { resolved: 2 }, parents);
1104 assert_ne!(one.op_id(), two.op_id());
1105 }
1106
1107 #[test]
1108 fn existing_add_function_op_id_is_unchanged_after_merge_added() {
1109 // Constructing the new Merge variant in the same enum must not
1110 // perturb the canonical bytes of existing variants. The golden
1111 // hash test below checks the literal value; this one verifies
1112 // the property holds even after a Merge op has been built.
1113 let _merge = Operation::new(
1114 OperationKind::Merge { resolved: 0 },
1115 ["op-x".into(), "op-y".into()],
1116 );
1117 let op = Operation::new(add_factorial(), []);
1118 assert_eq!(
1119 op.op_id(),
1120 "f112990d31ef2a63f3e5ca5680637ed36a54bc7e8230510ae0c0e93fcb39d104"
1121 );
1122 }
1123}