Skip to main content

trusty_memory/service/
core_kg.rs

1//! `MemoryService` knowledge-graph / dream / activity methods.
2//!
3//! Why: the `MemoryService` impl exceeded the 500-SLOC production cap once the
4//! former monolithic `service.rs` was split (issue #607); its KG, dream-cycle,
5//! and activity-listing methods form a cohesive second half hosted here.
6//! What: a continuation `impl MemoryService` block whose methods were moved
7//! verbatim from the original single impl.
8//! Test: covered by the corresponding `web::tests` / `service::tests`.
9
10use crate::kg_write::{CachePolicy, KgWriteError};
11use crate::{ActivityFilter, ActivitySource, DaemonEvent};
12use std::sync::Arc;
13use trusty_common::memory_core::dream::{DreamConfig, Dreamer, PersistedDreamStats};
14use trusty_common::memory_core::palace::PalaceId;
15// #4670: ExpandDirection drives the progressive `kg/graph/neighbors` route.
16use trusty_common::memory_core::store::kg::{ExpandDirection, Triple};
17use trusty_common::memory_core::{PalaceHandle, PalaceRegistry};
18
19use super::core::KG_GRAPH_MAX_TRIPLES;
20use super::helpers::{list_palaces_blocking, refresh_gaps_cache};
21use super::types::{
22    DreamStatusPayload, KgAssertBody, KgGraphPayload, KgNeighborsPayload, KgNodeView,
23    KgSeedPayload, ServiceError, ServiceResult,
24};
25use super::MemoryService;
26
27/// The three adjacency-derived graph counts, computed on the blocking pool.
28///
29/// Why (#7106): `community_count` runs a full Louvain partition the first time
30/// it is asked at a given KG write generation. `kg_graph` and `kg_graph_seed`
31/// asked for it inline on an async worker, so one call against a large palace
32/// parked that worker for the whole partition — and before the memo, every
33/// call did it again. Hopping once for all three keeps the executor free
34/// without spending three tasks on work that shares a lock.
35/// What: `(node_count, edge_count, community_count)` as `u64`, read under
36/// `tokio::task::spawn_blocking`. A join failure degrades to zeros, the same
37/// rule the counts already use for a poisoned adjacency, rather than failing a
38/// graph render.
39/// Test: `kg_graph_returns_active_triples`, `kg_graph_seed_ranks_by_degree`.
40async fn adjacency_counts(handle: &Arc<PalaceHandle>) -> (u64, u64, u64) {
41    let handle = Arc::clone(handle);
42    tokio::task::spawn_blocking(move || {
43        (
44            handle.kg.node_count() as u64,
45            handle.kg.edge_count() as u64,
46            handle.kg.community_count() as u64,
47        )
48    })
49    .await
50    .unwrap_or_else(|e| {
51        tracing::warn!("kg adjacency counts task failed: {e}");
52        (0, 0, 0)
53    })
54}
55
56// ---------------------------------------------------------------------------
57// KG list bounds
58// ---------------------------------------------------------------------------
59//
60// #4776: these live on the service layer, not on either consumer, because both
61// readers of the subject list must agree on them — the HTTP explorer routes in
62// `web::kg_routes` (compiled only under `axum-server`) and the MCP
63// `kg_list_subjects` tool in `tools::kg_ops` (always compiled). Defining them
64// in `web` would put them on the far side of that feature gate from the tool.
65
66/// Default page size for KG subject listings when the caller omits `limit`.
67///
68/// Why: 50 is large enough to feel responsive in the KG Explorer and to answer
69/// "what is in this graph?" in one call, without dumping a full graph.
70pub(crate) const DEFAULT_KG_LIST_LIMIT: usize = 50;
71
72/// Hard ceiling on `limit` for KG subject listings.
73///
74/// Why: prevent a misconfigured client from asking the daemon to materialize
75/// thousands of rows in one go; matches the spec's max=200.
76pub(crate) const MAX_KG_LIST_LIMIT: usize = 200;
77
78/// #8733: why every maintenance entry point (`dream_run`, the MCP dream and
79/// compact tools) refuses in a process that does not hold the lease.
80pub(crate) const MAINTENANCE_LEASE_NOT_HELD: &str =
81    "this process does not hold this data root's maintenance lease (another \
82     process holds it, or maintenance.lock could not be opened); see the pid in \
83     maintenance.lock (#8733)";
84
85impl MemoryService {
86    // -----------------------------------------------------------------
87    // Knowledge graph
88    // -----------------------------------------------------------------
89
90    /// Query the KG for all active triples whose subject matches.
91    pub async fn kg_query(&self, id: &str, subject: &str) -> ServiceResult<Vec<Triple>> {
92        let handle = self.open_handle(id)?;
93        handle
94            .kg
95            .query_active(subject)
96            .await
97            .map_err(|e| ServiceError::internal(format!("kg query: {e:#}")))
98    }
99
100    /// Assert a triple in the KG.
101    ///
102    /// Assert a triple through `POST /api/v1/palaces/{id}/kg`.
103    ///
104    /// Why: #4888 — this accepts an arbitrary predicate, so it can write a hot
105    /// one and must carry the same Tier S gate as the MCP tool. Not a
106    /// hypothetical path: `trusty-mpm`'s provisioner seeds its identity fact
107    /// through exactly this endpoint. #5524 — it also owed the prompt-cache
108    /// rebuild and never ran it, so a hot fact written here was stored and then
109    /// invisible to every later turn until some unrelated write rebuilt the
110    /// cache.
111    /// What: delegates the whole admission → assert → refresh sequence to
112    /// [`crate::kg_write::assert_triple`]. The variant split is what lets this
113    /// keep answering 400 for a refused write and 500 for a failed one.
114    /// Test: `http_kg_assert_endpoint_refreshes_prompt_cache`,
115    /// `http_kg_assert_endpoint_rejects_over_long_tier_s_object` in
116    /// `web::tests::prompt_tests`.
117    pub async fn kg_assert(&self, id: &str, body: KgAssertBody) -> ServiceResult<()> {
118        let handle = self.open_handle(id)?;
119        let triple = Triple {
120            subject: body.subject,
121            predicate: body.predicate,
122            object: body.object,
123            valid_from: chrono::Utc::now(),
124            valid_to: None,
125            confidence: body.confidence.unwrap_or(1.0),
126            provenance: body.provenance,
127        };
128        // #5524: route through the shared entry point so the prompt-cache
129        // rebuild cannot be forgotten here again.
130        crate::kg_write::assert_triple(&self.state, &handle, triple, CachePolicy::Inline)
131            .await
132            .map(|_| ())
133            .map_err(|e| match e {
134                KgWriteError::Admission(inner) => ServiceError::bad_request(format!("{inner:#}")),
135                other => ServiceError::internal(format!("{other}")),
136            })
137    }
138
139    /// Close the one active triple `(subject, predicate, object)`, leaving
140    /// every sibling object at that pair active. Returns the rows closed.
141    ///
142    /// Why: Issue #278 — the `DELETE /kg/triples/<id>` HTTP endpoint needs a
143    /// service-layer method so the HTTP handler stays a thin adapter. It took
144    /// no object and called the pair-level `KnowledgeGraph::retract`, whose
145    /// meaning is "close every active row at this pair", so a caller deleting
146    /// one triple lost the siblings it never named. Retraction is a soft close
147    /// — `close_active_row` copies the row to a `hist:` key first — so the
148    /// damage was recoverable, not silent data loss.
149    /// What: Opens the palace handle and calls
150    /// [`trusty_common::memory_core::store::kg::KnowledgeGraph::retract_triple`],
151    /// which keys on all three fields. Returns the closed count so the caller
152    /// can tell a retraction (`1`) from a miss (`0`); a miss is a genuine
153    /// no-op, which makes the call idempotent. Rebuilds the prompt cache when
154    /// a hot-predicate row was actually closed — otherwise a retracted Tier S
155    /// fact keeps being injected until the next write. This mirrors the
156    /// `kg_retract_triple` MCP tool so both surfaces agree.
157    /// Test: `kg_delete_triple_closes_one_object_and_keeps_siblings`,
158    /// `kg_delete_triple_returns_404_for_missing`,
159    /// `kg_delete_triple_rebuilds_prompt_cache_for_hot_predicate` in
160    /// `web::tests`. Only the third reaches the cache rebuild — the other two
161    /// retract under the predicate `is`, which is not hot.
162    pub async fn kg_retract_triple(
163        &self,
164        id: &str,
165        subject: &str,
166        predicate: &str,
167        object: &str,
168    ) -> ServiceResult<usize> {
169        let handle = self.open_handle(id)?;
170        let closed = handle
171            .kg
172            .retract_triple(subject, predicate, object)
173            .await
174            .map_err(|e| ServiceError::internal(format!("kg retract_triple: {e:#}")))?;
175        if closed > 0 && crate::prompt_facts::is_hot_predicate(predicate) {
176            // The write landed either way and the cache is only a
177            // denormalisation, so a rebuild failure is logged, not fatal.
178            // No test drives this arm: `rebuild_prompt_cache` skips a palace
179            // it cannot read and has no other fallible step, so it cannot
180            // currently return `Err`.
181            if let Err(e) = crate::prompt_facts::rebuild_prompt_cache(&self.state).await {
182                tracing::warn!("rebuild_prompt_cache after kg_retract_triple failed: {e:#}");
183            }
184        }
185        Ok(closed)
186    }
187
188    /// List distinct subjects in the KG.
189    pub async fn kg_list_subjects(&self, id: &str, limit: usize) -> ServiceResult<Vec<String>> {
190        let handle = self.open_handle(id)?;
191        handle
192            .kg
193            .list_subjects(limit)
194            .map_err(|e| ServiceError::internal(format!("kg list_subjects: {e:#}")))
195    }
196
197    /// List distinct subjects in the KG paired with their active-triple count.
198    pub async fn kg_list_subjects_with_counts(
199        &self,
200        id: &str,
201        limit: usize,
202    ) -> ServiceResult<Vec<(String, u64)>> {
203        let handle = self.open_handle(id)?;
204        handle
205            .kg
206            .list_subjects_with_counts(limit)
207            .map_err(|e| ServiceError::internal(format!("kg list_subjects_with_counts: {e:#}")))
208    }
209
210    /// Page through every active triple.
211    pub async fn kg_list_all(
212        &self,
213        id: &str,
214        limit: usize,
215        offset: usize,
216    ) -> ServiceResult<Vec<Triple>> {
217        let handle = self.open_handle(id)?;
218        handle
219            .kg
220            .list_active(limit, offset)
221            .await
222            .map_err(|e| ServiceError::internal(format!("kg list_active: {e:#}")))
223    }
224
225    /// Return the count of currently-active triples.
226    ///
227    /// #5384: a failed count read is a 500, not `{"active": 0}` — the badge
228    /// this feeds cannot tell those apart.
229    pub async fn kg_count(&self, id: &str) -> ServiceResult<usize> {
230        let handle = self.open_handle(id)?;
231        handle.kg.count_active_triples().map_err(|e| {
232            ServiceError::internal(format!("kg count_active_triples for palace {id}: {e:#}"))
233        })
234    }
235
236    /// Build the per-palace visual graph payload.
237    ///
238    /// Why (issue #4670): the `node_count` / `edge_count` / `community_count`
239    /// here are computed over the FULL adjacency while `triples` is capped at
240    /// [`KG_GRAPH_MAX_TRIPLES`]. That mismatch used to be invisible — the UI
241    /// rendered 5,000 triples under a "9,311 nodes" badge — and because
242    /// `list_active` orders by `valid_from` DESC the dropped triples were
243    /// silently the oldest. The payload now reports what it actually returned
244    /// alongside what exists, so truncation is machine-detectable.
245    /// What: unchanged query; adds `returned_triple_count`,
246    /// `active_triple_count`, and the derived `truncated` flag.
247    /// Test: `kg_graph_signals_truncation`, `kg_graph_returns_active_triples`.
248    pub async fn kg_graph(&self, id: &str) -> ServiceResult<KgGraphPayload> {
249        self.kg_graph_with_cap(id, KG_GRAPH_MAX_TRIPLES).await
250    }
251
252    /// [`Self::kg_graph`] with an explicit triple cap.
253    ///
254    /// Why (issue #4670): the truncation-signalling branch is only reachable
255    /// above `KG_GRAPH_MAX_TRIPLES` (5,000), and seeding 5,001 triples costs
256    /// ~90 s of test time — expensive enough that the branch would in practice
257    /// go untested. Taking the cap as a parameter makes it provable with five
258    /// triples and a cap of three, at no cost to the production call path.
259    /// What: the real implementation; `kg_graph` is a thin wrapper that passes
260    /// the production constant.
261    /// Test: `kg_graph_signals_truncation`.
262    pub async fn kg_graph_with_cap(
263        &self,
264        id: &str,
265        max_triples: usize,
266    ) -> ServiceResult<KgGraphPayload> {
267        let handle = self.open_handle(id)?;
268        let triples = handle
269            .kg
270            .list_active(max_triples, 0)
271            .await
272            .map_err(|e| ServiceError::internal(format!("kg list_active: {e:#}")))?;
273        // #4670: compare against the true active count, not the cap, so a
274        // palace sitting exactly on the cap is not falsely flagged.
275        // #5384: a failed read would come back as 0 and make `truncated` false
276        // for every payload, which is the flag's exact failure mode.
277        let active_triple_count = handle.kg.count_active_triples().map_err(|e| {
278            ServiceError::internal(format!("kg count_active_triples for palace {id}: {e:#}"))
279        })? as u64;
280        let returned_triple_count = triples.len() as u64;
281        // #7106: `community_count` runs a Louvain partition on the first read
282        // after a write, so it never runs on an async worker. The three
283        // adjacency-derived counts share one hop.
284        let (node_count, edge_count, community_count) = adjacency_counts(&handle).await;
285        Ok(KgGraphPayload {
286            triples,
287            node_count,
288            edge_count,
289            community_count,
290            returned_triple_count,
291            active_triple_count,
292            truncated: returned_triple_count < active_triple_count,
293        })
294    }
295
296    /// Top-`limit` nodes by degree plus the edges among them (issue #4670).
297    ///
298    /// Why: first paint must show the graph's skeleton, not 9,311 nodes in an
299    /// O(n²) layout. Measured on the live 8,266-triple palace, 90.2% of nodes
300    /// are degree-1 leaves and only 7.2% have degree >= 5, so a
301    /// top-degree slice carries essentially all of the visible structure and
302    /// everything else stays one click away.
303    /// What: runs `KnowledgeGraph::top_degree_subgraph` over the resident
304    /// adjacency (O(V log V + E), no disk I/O) and pairs the result with the
305    /// palace-wide totals the header needs to report honestly.
306    /// Test: `kg_graph_seed_ranks_by_degree`, `kg_graph_seed_clamps_limit`.
307    pub async fn kg_graph_seed(&self, id: &str, limit: usize) -> ServiceResult<KgSeedPayload> {
308        let handle = self.open_handle(id)?;
309        let (nodes, triples) = handle
310            .kg
311            .top_degree_subgraph(limit)
312            .map_err(|e| ServiceError::internal(format!("kg top_degree_subgraph: {e:#}")))?;
313        // #7106: same reason as `kg_graph_with_cap` — off the async worker.
314        let (node_count, edge_count, community_count) = adjacency_counts(&handle).await;
315        let returned_node_count = nodes.len() as u64;
316        Ok(KgSeedPayload {
317            nodes: nodes.into_iter().map(KgNodeView::from).collect(),
318            returned_triple_count: triples.len() as u64,
319            triples,
320            node_count,
321            edge_count,
322            community_count,
323            returned_node_count,
324            limit: limit as u64,
325            truncated: returned_node_count < node_count,
326        })
327    }
328
329    /// Direction-aware, hop-bounded expansion around one node (issue #4670).
330    ///
331    /// Why: click-to-expand needs "what points AT this node", which no HTTP
332    /// endpoint could answer — `kg_query` is a subject prefix scan. Bounding
333    /// the hops keeps one click on a hub from pulling the whole palace.
334    /// What: delegates to `KnowledgeGraph::expand_neighbors`. `direction`
335    /// and `max_hops` are already validated/clamped by the HTTP layer; they
336    /// are echoed back so the client can see what actually ran.
337    /// Test: `kg_neighbors_returns_incoming_edges`, `kg_neighbors_clamps_max_hops`.
338    pub async fn kg_neighbors(
339        &self,
340        id: &str,
341        node: &str,
342        direction: ExpandDirection,
343        max_hops: usize,
344    ) -> ServiceResult<KgNeighborsPayload> {
345        let handle = self.open_handle(id)?;
346        let (nodes, triples) = handle
347            .kg
348            .expand_neighbors(node, direction, max_hops)
349            .map_err(|e| ServiceError::internal(format!("kg expand_neighbors: {e:#}")))?;
350        Ok(KgNeighborsPayload {
351            origin: node.to_string(),
352            returned_node_count: nodes.len() as u64,
353            returned_triple_count: triples.len() as u64,
354            nodes: nodes.into_iter().map(KgNodeView::from).collect(),
355            triples,
356            direction: match direction {
357                ExpandDirection::In => "in",
358                ExpandDirection::Out => "out",
359                ExpandDirection::Both => "both",
360            }
361            .to_string(),
362            max_hops: max_hops as u64,
363        })
364    }
365
366    // -----------------------------------------------------------------
367    // Dream cycle
368    // -----------------------------------------------------------------
369
370    /// Aggregate dream stats across every persisted palace.
371    pub async fn dream_status_aggregate(&self) -> DreamStatusPayload {
372        let palaces = PalaceRegistry::list_palaces(&self.state.data_root).unwrap_or_default();
373        let mut out = DreamStatusPayload::default();
374        let mut latest: Option<chrono::DateTime<chrono::Utc>> = None;
375        for p in palaces {
376            let data_dir = self.state.data_root.join(p.id.as_str());
377            let snap = match PersistedDreamStats::load(&data_dir) {
378                Ok(Some(s)) => s,
379                _ => continue,
380            };
381            out.merged = out.merged.saturating_add(snap.stats.merged);
382            out.pruned = out.pruned.saturating_add(snap.stats.pruned);
383            out.compacted = out.compacted.saturating_add(snap.stats.compacted);
384            out.closets_updated = out
385                .closets_updated
386                .saturating_add(snap.stats.closets_updated);
387            out.duration_ms = out.duration_ms.saturating_add(snap.stats.duration_ms);
388            latest = match latest {
389                Some(t) if t >= snap.last_run_at => Some(t),
390                _ => Some(snap.last_run_at),
391            };
392        }
393        out.last_run_at = latest;
394        out
395    }
396
397    /// Per-palace dream stats snapshot.
398    pub async fn dream_status_for_palace(&self, id: &str) -> ServiceResult<DreamStatusPayload> {
399        let data_dir = self.state.data_root.join(id);
400        if !data_dir.exists() {
401            return Err(ServiceError::not_found(format!("palace not found: {id}")));
402        }
403        match PersistedDreamStats::load(&data_dir) {
404            Ok(Some(s)) => Ok(s.into()),
405            Ok(None) => Ok(DreamStatusPayload::default()),
406            Err(e) => Err(ServiceError::internal(format!("read dream stats: {e:#}"))),
407        }
408    }
409
410    /// Run a dream cycle across every palace.
411    ///
412    /// Why (issue #4637): like `recall_all`, this route is deliberately NOT
413    /// converted to `PalaceRegistry::peek`. Dreaming is a maintenance pass —
414    /// consolidating only the 64 palaces that happen to be cache-resident
415    /// would silently stop maintaining the other ~5,730, which is a worse
416    /// failure than a slow run because nothing reports it. Opening every
417    /// palace stays correct; what changes is that the blocking open no longer
418    /// runs inline on a tokio worker thread. A long dream run is expected —
419    /// this is an explicitly-triggered `POST`, not a page load.
420    /// What: lists palaces on the blocking pool, then per palace hops to
421    /// `spawn_blocking` for the open before awaiting the async dream cycle.
422    /// Test: `dream_run_aggregates_stats`.
423    pub async fn dream_run(&self) -> ServiceResult<DreamStatusPayload> {
424        // #8733: a dream run is maintenance; only the lease holder runs it.
425        if !self.state.registry.may_run_maintenance() {
426            return Err(ServiceError::conflict(MAINTENANCE_LEASE_NOT_HELD));
427        }
428        let palaces = list_palaces_blocking(&self.state)
429            .await
430            .map_err(|e| ServiceError::internal(format!("{e:#}")))?;
431        let dreamer = Dreamer::new(DreamConfig::default());
432        let mut out = DreamStatusPayload::default();
433        for p in palaces {
434            // #4637: open_palace (not peek) is deliberate — a dream cycle must
435            // maintain every palace; the spawn_blocking hop keeps the cold open
436            // off the async executor.
437            let registry = std::sync::Arc::clone(&self.state.registry);
438            let root = self.state.data_root.clone();
439            let pid = p.id.clone();
440            let opened =
441                tokio::task::spawn_blocking(move || registry.open_palace(&root, &pid)).await;
442            let handle = match opened {
443                Ok(Ok(h)) => h,
444                Ok(Err(e)) => {
445                    tracing::warn!(palace = %p.id, "dream_run: open failed: {e:#}");
446                    continue;
447                }
448                Err(e) => {
449                    tracing::warn!(palace = %p.id, "dream_run: join open failed: {e}");
450                    continue;
451                }
452            };
453            match dreamer.dream_cycle(&handle).await {
454                Ok(stats) => {
455                    out.merged = out.merged.saturating_add(stats.merged);
456                    out.pruned = out.pruned.saturating_add(stats.pruned);
457                    out.compacted = out.compacted.saturating_add(stats.compacted);
458                    out.closets_updated = out.closets_updated.saturating_add(stats.closets_updated);
459                    out.duration_ms = out.duration_ms.saturating_add(stats.duration_ms);
460                }
461                Err(e) => tracing::warn!(palace = %p.id, "dream_run: cycle failed: {e:#}"),
462            }
463            refresh_gaps_cache(&self.state, &handle).await;
464        }
465        out.last_run_at = Some(chrono::Utc::now());
466        self.state.emit(DaemonEvent::DreamCompleted {
467            palace_id: None,
468            merged: out.merged,
469            pruned: out.pruned,
470            compacted: out.compacted,
471            closets_updated: out.closets_updated,
472            duration_ms: out.duration_ms,
473            source: ActivitySource::Http,
474        });
475        self.state.emit(self.aggregate_status_event());
476        Ok(out)
477    }
478
479    // -----------------------------------------------------------------
480    // Activity log
481    // -----------------------------------------------------------------
482
483    /// Paginated activity-log read.
484    pub async fn list_activity(
485        &self,
486        filter: ActivityFilter,
487        limit: usize,
488        offset: usize,
489    ) -> ServiceResult<(Vec<crate::ActivityEntry>, u64)> {
490        let entries = self
491            .state
492            .activity_log
493            .list(&filter, limit, offset)
494            .map_err(|e| ServiceError::internal(format!("activity list: {e:#}")))?;
495        let total = self
496            .state
497            .activity_log
498            .count()
499            .map_err(|e| ServiceError::internal(format!("activity count: {e:#}")))?;
500        Ok((entries, total))
501    }
502
503    // -----------------------------------------------------------------
504    // Internal helper — open a palace handle, 404 only on a genuine absence.
505    // -----------------------------------------------------------------
506
507    /// Open the named palace.
508    ///
509    /// Why (#5549, ADR-0045): this mapped every `open_palace` failure to
510    /// `NotFound`, which the HTTP layer renders as 404. A denied or transient
511    /// read of `palace.json`, undecodable metadata, an open-queue timeout, or a
512    /// redb write-lock conflict then all reported that the palace does not
513    /// exist — erasing at the caller the distinction `load_palace` draws, and
514    /// across a much wider surface than the two rename paths: every
515    /// `/api/v1/palaces/{id}/kg*` endpoint, the drawer CRUD routes, and
516    /// per-palace recall reach this one helper.
517    /// What: returns `ServiceError::NotFound` only when
518    /// `PalaceRegistry::open_error_is_absent` confirms the palace is genuinely
519    /// not there, and `ServiceError::Internal` (500) otherwise.
520    /// Test: `unreadable_palace_is_500_not_404_at_the_service_open_handle`,
521    /// `unstattable_palace_is_500_not_404_at_the_service_open_handle`,
522    /// `absent_palace_is_still_404_at_both_open_handles`.
523    pub fn open_handle(&self, id: &str) -> ServiceResult<Arc<PalaceHandle>> {
524        self.state
525            .registry
526            .open_palace(&self.state.data_root, &PalaceId::new(id))
527            .map_err(|e| {
528                // #5549: every open failure mapped to 404, so a palace that
529                // could not be read was reported as one that is not there.
530                if PalaceRegistry::open_error_is_absent(&e) {
531                    ServiceError::not_found(format!("palace not found: {id} ({e:#})"))
532                } else {
533                    ServiceError::internal(format!("palace could not be loaded: {id} ({e:#})"))
534                }
535            })
536    }
537}