1use std::collections::BTreeMap;
9use std::error::Error;
10use std::fmt;
11
12use lino_objects_codec::format::parse_indented;
13
14use crate::engine::{normalize_prompt, stable_id, GraphEdge, GraphNode, KNOWLEDGE_SCHEMA_VERSION};
15use crate::link_store::{DoubletLink, LinkRecord};
16use crate::links_format::push_lino_node;
17use crate::seed::parser::{parse_lino, LinoNode};
18use crate::skill_compiler::CompiledSkillPackage;
19
20#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct PackageDependency {
23 pub package_id: String,
24 pub version: String,
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct PackageHandler {
30 pub id: String,
31 pub kind: String,
32 pub capability: String,
33 pub response: String,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PackageTrigger {
39 pub id: String,
40 pub kind: String,
41 pub match_prompt: String,
42 pub normalized_match: String,
43 pub handler_id: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct PackagePermission {
49 pub id: String,
50 pub capability: String,
51 pub effect: String,
52 pub description: String,
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct AssociativePackage {
58 pub id: String,
59 pub name: String,
60 pub version: String,
61 pub dependencies: Vec<PackageDependency>,
62 pub handlers: Vec<PackageHandler>,
63 pub triggers: Vec<PackageTrigger>,
64 pub permissions: Vec<PackagePermission>,
65}
66
67#[derive(Debug, Clone, PartialEq, Eq)]
69pub struct PackageReplay {
70 pub package_id: String,
71 pub trigger_id: String,
72 pub handler_id: String,
73 pub answer: String,
74 pub cache_hit: String,
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum PackagePermissionDecision {
80 Allowed {
81 package_id: String,
82 permission_id: String,
83 capability: String,
84 },
85 Denied {
86 capability: String,
87 reason: String,
88 },
89}
90
91#[derive(Debug, Default, Clone, PartialEq, Eq)]
93pub struct PackageStore {
94 packages: BTreeMap<String, AssociativePackage>,
95}
96
97#[derive(Debug, Clone, PartialEq, Eq)]
99pub enum PackageInstallError {
100 MissingDependency {
101 package_id: String,
102 dependency_id: String,
103 },
104 VersionMismatch {
105 package_id: String,
106 dependency_id: String,
107 required: String,
108 installed: String,
109 },
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
114pub enum PackageImportError {
115 EmptyDocument,
116 IllFormedLinksNotation(String),
117 NotAssociativePackage(String),
118 MissingField(&'static str),
119}
120
121impl fmt::Display for PackageInstallError {
122 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
123 match self {
124 Self::MissingDependency {
125 package_id,
126 dependency_id,
127 } => write!(
128 formatter,
129 "package {package_id} requires missing dependency {dependency_id}"
130 ),
131 Self::VersionMismatch {
132 package_id,
133 dependency_id,
134 required,
135 installed,
136 } => write!(
137 formatter,
138 "package {package_id} requires {dependency_id}@{required}, installed {installed}"
139 ),
140 }
141 }
142}
143
144impl Error for PackageInstallError {}
145
146impl fmt::Display for PackageImportError {
147 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
148 match self {
149 Self::EmptyDocument => write!(formatter, "package document is empty"),
150 Self::IllFormedLinksNotation(message) => {
151 write!(formatter, "ill-formed package Links Notation: {message}")
152 }
153 Self::NotAssociativePackage(id) => {
154 write!(formatter, "{id} is not an associative package")
155 }
156 Self::MissingField(field) => write!(formatter, "package is missing {field}"),
157 }
158 }
159}
160
161impl Error for PackageImportError {}
162
163impl PackageHandler {
164 #[must_use]
165 pub fn new(id: &str, kind: &str, capability: &str) -> Self {
166 Self {
167 id: id.to_owned(),
168 kind: kind.to_owned(),
169 capability: capability.to_owned(),
170 response: String::new(),
171 }
172 }
173
174 #[must_use]
175 pub fn with_response(mut self, response: &str) -> Self {
176 response.clone_into(&mut self.response);
177 self
178 }
179}
180
181impl PackageTrigger {
182 #[must_use]
183 pub fn new(id: &str, kind: &str, match_prompt: &str, handler_id: &str) -> Self {
184 Self {
185 id: id.to_owned(),
186 kind: kind.to_owned(),
187 match_prompt: match_prompt.to_owned(),
188 normalized_match: normalize_prompt(match_prompt),
189 handler_id: handler_id.to_owned(),
190 }
191 }
192}
193
194impl AssociativePackage {
195 #[must_use]
196 pub fn new(id: &str, name: &str, version: &str) -> Self {
197 Self {
198 id: id.to_owned(),
199 name: name.to_owned(),
200 version: version.to_owned(),
201 dependencies: Vec::new(),
202 handlers: Vec::new(),
203 triggers: Vec::new(),
204 permissions: Vec::new(),
205 }
206 }
207
208 #[must_use]
209 pub fn from_compiled_skill(
210 id: &str,
211 name: &str,
212 version: &str,
213 skill: &CompiledSkillPackage,
214 ) -> Self {
215 let mut package = Self::new(id, name, version)
216 .with_handler(
217 PackageHandler::new(
218 &skill.handler_id,
219 "deterministic_response",
220 &format!("handler:{}", skill.handler_id),
221 )
222 .with_response(&skill.response),
223 )
224 .with_trigger(PackageTrigger {
225 id: skill.rule_id.clone(),
226 kind: String::from("exact_normalized_prompt"),
227 match_prompt: skill.trigger.clone(),
228 normalized_match: skill.normalized_trigger.clone(),
229 handler_id: skill.handler_id.clone(),
230 });
231 for test in &skill.expected_tests {
232 if test.normalized_input == skill.normalized_trigger
233 && test.expected_output == skill.response
234 {
235 continue;
236 }
237 package = package
238 .with_handler(
239 PackageHandler::new(
240 &test.handler_id,
241 "deterministic_response",
242 &format!("handler:{}", test.handler_id),
243 )
244 .with_response(&test.expected_output),
245 )
246 .with_trigger(PackageTrigger {
247 id: test.trigger_id.clone(),
248 kind: String::from("exact_normalized_prompt"),
249 match_prompt: test.input.clone(),
250 normalized_match: test.normalized_input.clone(),
251 handler_id: test.handler_id.clone(),
252 });
253 }
254 for permission in &skill.required_permissions {
255 package = package.with_permission(&permission.capability, &permission.description);
256 }
257 package
258 }
259
260 #[must_use]
261 pub fn with_dependency(mut self, package_id: &str, version: &str) -> Self {
262 self.dependencies.push(PackageDependency {
263 package_id: package_id.to_owned(),
264 version: version.to_owned(),
265 });
266 self
267 }
268
269 #[must_use]
270 pub fn with_handler(mut self, handler: PackageHandler) -> Self {
271 self.handlers.push(handler);
272 self
273 }
274
275 #[must_use]
276 pub fn with_trigger(mut self, trigger: PackageTrigger) -> Self {
277 self.triggers.push(trigger);
278 self
279 }
280
281 #[must_use]
282 pub fn with_permission(mut self, capability: &str, description: &str) -> Self {
283 let id = stable_id(
284 "package_permission",
285 &format!("{}:{capability}:{description}", self.id),
286 );
287 self.permissions.push(PackagePermission {
288 id,
289 capability: capability.to_owned(),
290 effect: String::from("allow"),
291 description: description.to_owned(),
292 });
293 self
294 }
295
296 #[must_use]
297 pub fn replay(&self, prompt: &str) -> Option<PackageReplay> {
298 let normalized = normalize_prompt(prompt);
299 let trigger = self.triggers.iter().find(|trigger| {
300 trigger.kind == "exact_normalized_prompt" && trigger.normalized_match == normalized
301 })?;
302 let handler = self
303 .handlers
304 .iter()
305 .find(|handler| handler.id == trigger.handler_id)?;
306 if handler.kind != "deterministic_response" || handler.response.is_empty() {
307 return None;
308 }
309 Some(PackageReplay {
310 package_id: self.id.clone(),
311 trigger_id: trigger.id.clone(),
312 handler_id: handler.id.clone(),
313 answer: handler.response.clone(),
314 cache_hit: self.id.clone(),
315 })
316 }
317
318 #[must_use]
319 pub fn links_notation(&self) -> String {
320 let mut out = String::new();
321 push_lino_node(&mut out, 0, &self.id, None);
322 push_lino_node(&mut out, 2, "type", Some("associative_package"));
323 push_lino_node(
324 &mut out,
325 2,
326 "schema_version",
327 Some(KNOWLEDGE_SCHEMA_VERSION),
328 );
329 push_lino_node(&mut out, 2, "name", Some(&self.name));
330 push_lino_node(&mut out, 2, "version", Some(&self.version));
331 push_lino_node(
332 &mut out,
333 2,
334 "source",
335 Some("R65 Deep.Foundation-inspired local package model"),
336 );
337 for dependency in &self.dependencies {
338 push_lino_node(&mut out, 2, "dependency", Some(&dependency.package_id));
339 push_lino_node(&mut out, 4, "version", Some(&dependency.version));
340 }
341 for handler in &self.handlers {
342 push_lino_node(&mut out, 2, "handler", Some(&handler.id));
343 push_lino_node(&mut out, 4, "kind", Some(&handler.kind));
344 push_lino_node(&mut out, 4, "capability", Some(&handler.capability));
345 push_lino_node(&mut out, 4, "response", Some(&handler.response));
346 }
347 for trigger in &self.triggers {
348 push_lino_node(&mut out, 2, "trigger", Some(&trigger.id));
349 push_lino_node(&mut out, 4, "kind", Some(&trigger.kind));
350 push_lino_node(&mut out, 4, "match_prompt", Some(&trigger.match_prompt));
351 push_lino_node(
352 &mut out,
353 4,
354 "normalized_match",
355 Some(&trigger.normalized_match),
356 );
357 push_lino_node(&mut out, 4, "handler", Some(&trigger.handler_id));
358 }
359 for permission in &self.permissions {
360 push_lino_node(&mut out, 2, "permission", Some(&permission.id));
361 push_lino_node(&mut out, 4, "effect", Some(&permission.effect));
362 push_lino_node(&mut out, 4, "capability", Some(&permission.capability));
363 push_lino_node(&mut out, 4, "description", Some(&permission.description));
364 }
365 out.trim_end().to_owned()
366 }
367
368 pub fn from_links_notation(text: &str) -> Result<Self, PackageImportError> {
369 let trimmed = text.trim();
370 if trimmed.is_empty() {
371 return Err(PackageImportError::EmptyDocument);
372 }
373 parse_indented(trimmed)
374 .map_err(|error| PackageImportError::IllFormedLinksNotation(format!("{error:?}")))?;
375 let tree = parse_lino(trimmed);
376 let root = tree
377 .children
378 .first()
379 .ok_or(PackageImportError::EmptyDocument)?;
380 if root.find_child_value("type") != "associative_package" {
381 return Err(PackageImportError::NotAssociativePackage(root.name.clone()));
382 }
383 let name = required_child(root, "name")?;
384 let version = required_child(root, "version")?;
385 let mut package = Self::new(&root.name, name, version);
386 for child in &root.children {
387 match child.name.as_str() {
388 "dependency" => {
389 package.dependencies.push(PackageDependency {
390 package_id: child.id.clone(),
391 version: child.find_child_value("version").to_owned(),
392 });
393 }
394 "handler" => {
395 package.handlers.push(PackageHandler {
396 id: child.id.clone(),
397 kind: child.find_child_value("kind").to_owned(),
398 capability: child.find_child_value("capability").to_owned(),
399 response: child.find_child_value("response").to_owned(),
400 });
401 }
402 "trigger" => {
403 package.triggers.push(PackageTrigger {
404 id: child.id.clone(),
405 kind: child.find_child_value("kind").to_owned(),
406 match_prompt: child.find_child_value("match_prompt").to_owned(),
407 normalized_match: child.find_child_value("normalized_match").to_owned(),
408 handler_id: child.find_child_value("handler").to_owned(),
409 });
410 }
411 "permission" => {
412 package.permissions.push(PackagePermission {
413 id: child.id.clone(),
414 effect: child.find_child_value("effect").to_owned(),
415 capability: child.find_child_value("capability").to_owned(),
416 description: child.find_child_value("description").to_owned(),
417 });
418 }
419 _ => {}
420 }
421 }
422 Ok(package)
423 }
424
425 #[must_use]
426 pub fn grants_capability(&self, capability: &str) -> Option<&PackagePermission> {
427 self.permissions
428 .iter()
429 .find(|permission| permission.effect == "allow" && permission.capability == capability)
430 }
431
432 #[must_use]
433 pub fn link_records(&self) -> Vec<LinkRecord> {
434 let mut records = vec![link_record(
435 &self.id,
436 "AssociativePackage",
437 "associative_package",
438 "R65",
439 &[
440 ("name", self.name.as_str()),
441 ("version", self.version.as_str()),
442 ("source", "Deep.Foundation-inspired local package model"),
443 ],
444 )];
445 for dependency in &self.dependencies {
446 records.push(link_record(
447 &stable_id(
448 "package_dependency",
449 &format!("{}:{}", self.id, dependency.package_id),
450 ),
451 "PackageDependency",
452 "dependency_link",
453 &self.id,
454 &[
455 ("package_id", dependency.package_id.as_str()),
456 ("version", dependency.version.as_str()),
457 ],
458 ));
459 }
460 for handler in &self.handlers {
461 records.push(link_record(
462 &handler.id,
463 "PackageHandler",
464 "compiled_handler",
465 &self.id,
466 &[
467 ("kind", handler.kind.as_str()),
468 ("capability", handler.capability.as_str()),
469 ],
470 ));
471 }
472 for trigger in &self.triggers {
473 records.push(link_record(
474 &trigger.id,
475 "PackageTrigger",
476 "trigger_rule",
477 &self.id,
478 &[
479 ("kind", trigger.kind.as_str()),
480 ("match_prompt", trigger.match_prompt.as_str()),
481 ("handler", trigger.handler_id.as_str()),
482 ],
483 ));
484 }
485 for permission in &self.permissions {
486 records.push(link_record(
487 &permission.id,
488 "PackagePermission",
489 "permission_grant",
490 &self.id,
491 &[
492 ("effect", permission.effect.as_str()),
493 ("capability", permission.capability.as_str()),
494 ("description", permission.description.as_str()),
495 ],
496 ));
497 }
498 records
499 }
500}
501
502impl PackageStore {
503 #[must_use]
504 pub const fn new() -> Self {
505 Self {
506 packages: BTreeMap::new(),
507 }
508 }
509
510 pub fn install(&mut self, package: AssociativePackage) -> Result<(), PackageInstallError> {
511 for dependency in &package.dependencies {
512 let Some(installed) = self.packages.get(&dependency.package_id) else {
513 return Err(PackageInstallError::MissingDependency {
514 package_id: package.id.clone(),
515 dependency_id: dependency.package_id.clone(),
516 });
517 };
518 if !dependency.version.is_empty() && installed.version != dependency.version {
519 return Err(PackageInstallError::VersionMismatch {
520 package_id: package.id.clone(),
521 dependency_id: dependency.package_id.clone(),
522 required: dependency.version.clone(),
523 installed: installed.version.clone(),
524 });
525 }
526 }
527 self.packages.insert(package.id.clone(), package);
528 Ok(())
529 }
530
531 #[must_use]
532 pub fn replay(&self, prompt: &str) -> Option<PackageReplay> {
533 self.packages
534 .values()
535 .find_map(|package| package.replay(prompt))
536 }
537
538 #[must_use]
539 pub fn permission_for_capability(&self, capability: &str) -> PackagePermissionDecision {
540 for package in self.packages.values() {
541 if let Some(permission) = package.grants_capability(capability) {
542 return PackagePermissionDecision::Allowed {
543 package_id: package.id.clone(),
544 permission_id: permission.id.clone(),
545 capability: capability.to_owned(),
546 };
547 }
548 }
549 PackagePermissionDecision::Denied {
550 capability: capability.to_owned(),
551 reason: format!("no installed associative package grants {capability}"),
552 }
553 }
554
555 #[must_use]
556 pub fn permission_for_tool(&self, tool_name: &str) -> PackagePermissionDecision {
557 self.permission_for_capability(&format!("tool:{tool_name}"))
558 }
559
560 #[must_use]
561 pub fn packages(&self) -> Vec<&AssociativePackage> {
562 self.packages.values().collect()
563 }
564}
565
566#[must_use]
567pub fn default_associative_packages() -> Vec<AssociativePackage> {
568 vec![
569 AssociativePackage::new(
570 "pkg_formal_ai_core",
571 "formal-ai core package",
572 KNOWLEDGE_SCHEMA_VERSION,
573 )
574 .with_handler(PackageHandler::new(
575 "handler_calculator",
576 "rust_handler",
577 "tool:calculator",
578 ))
579 .with_trigger(PackageTrigger::new(
580 "trigger_calculator_tool_call",
581 "tool_invocation",
582 "calculator",
583 "handler_calculator",
584 ))
585 .with_handler(PackageHandler::new(
586 "handler_web_search",
587 "rust_handler",
588 "tool:web_search",
589 ))
590 .with_trigger(PackageTrigger::new(
591 "trigger_web_search_tool_call",
592 "tool_invocation",
593 "web_search",
594 "handler_web_search",
595 ))
596 .with_handler(PackageHandler::new(
597 "handler_javascript_execution",
598 "rust_handler",
599 "tool:javascript_execution",
600 ))
601 .with_trigger(PackageTrigger::new(
602 "trigger_javascript_execution_tool_call",
603 "tool_invocation",
604 "javascript_execution",
605 "handler_javascript_execution",
606 ))
607 .with_permission("tool:calculator", "local deterministic calculator tool")
608 .with_permission("tool:web_search", "browser-backed web search API")
609 .with_permission(
610 "tool:javascript_execution",
611 "bounded deterministic JavaScript expression execution",
612 )
613 .with_permission("tool:concept_lookup", "seed-backed concept lookup")
614 .with_permission(
615 "tool:write_program",
616 "seed-backed program template renderer",
617 ),
618 AssociativePackage::new(
626 "pkg_agentic_coding",
627 "agentic-coding capability package",
628 KNOWLEDGE_SCHEMA_VERSION,
629 )
630 .with_permission(
631 "tool:web_fetch",
632 "agentic-coding HTTP source fetch (client-executed)",
633 )
634 .with_permission(
635 "tool:write_file",
636 "agentic-coding workspace file write (client-executed)",
637 )
638 .with_permission(
639 "tool:run_command",
640 "agentic-coding sandboxed command runner (client-executed)",
641 )
642 .with_permission(
652 "tool:capability:search",
653 "agentic-coding web search (any CLI naming, client-executed)",
654 )
655 .with_permission(
656 "tool:capability:fetch",
657 "agentic-coding source fetch (any CLI naming, client-executed)",
658 )
659 .with_permission(
660 "tool:capability:read",
661 "agentic-coding file read (any CLI naming, client-executed)",
662 )
663 .with_permission(
664 "tool:capability:write",
665 "agentic-coding file write (any CLI naming, client-executed)",
666 )
667 .with_permission(
668 "tool:capability:edit",
669 "agentic-coding file edit (any CLI naming, client-executed)",
670 )
671 .with_permission(
672 "tool:capability:run",
673 "agentic-coding command runner (any CLI naming, client-executed)",
674 )
675 .with_permission(
676 "tool:capability:grep",
677 "agentic-coding code search (any CLI naming, client-executed)",
678 )
679 .with_permission(
680 "tool:capability:glob",
681 "agentic-coding file glob (any CLI naming, client-executed)",
682 )
683 .with_permission(
684 "tool:capability:list_dir",
685 "agentic-coding directory listing (any CLI naming, client-executed)",
686 )
687 .with_permission(
688 "tool:capability:todo",
689 "agentic-coding planning scratchpad (any CLI naming, client-executed)",
690 )
691 .with_permission(
692 "tool:capability:subagent",
693 "agentic-coding delegation (any CLI naming, client-executed)",
694 )
695 .with_permission(
696 "tool:capability:read_many",
697 "agentic-coding multi-file read (any CLI naming, client-executed)",
698 )
699 .with_permission(
700 "tool:capability:multi_edit",
701 "agentic-coding multi-file edit (any CLI naming, client-executed)",
702 ),
703 ]
704}
705
706#[must_use]
707pub fn default_package_store() -> PackageStore {
708 let mut store = PackageStore::new();
709 for package in default_associative_packages() {
710 store
711 .install(package)
712 .expect("default packages have no invalid dependencies");
713 }
714 store
715}
716
717#[must_use]
718pub(crate) fn default_package_graph_projection() -> (Vec<GraphNode>, Vec<GraphEdge>) {
719 let mut nodes = Vec::new();
720 let mut edges = Vec::new();
721 for package in default_associative_packages() {
722 nodes.push(GraphNode {
723 id: package.id.clone(),
724 label: package.name.clone(),
725 links_notation: package.links_notation(),
726 });
727 edges.push(GraphEdge {
728 from: String::from("formal_ai_knowledge"),
729 to: package.id.clone(),
730 role: String::from("package"),
731 });
732 for handler in &package.handlers {
733 nodes.push(GraphNode {
734 id: handler.id.clone(),
735 label: format!("Package handler: {}", handler.capability),
736 links_notation: format!("{} kind={}", handler.id, handler.kind),
737 });
738 edges.push(GraphEdge {
739 from: package.id.clone(),
740 to: handler.id.clone(),
741 role: String::from("package_handler"),
742 });
743 }
744 for trigger in &package.triggers {
745 nodes.push(GraphNode {
746 id: trigger.id.clone(),
747 label: format!("Package trigger: {}", trigger.kind),
748 links_notation: format!("{} handler={}", trigger.id, trigger.handler_id),
749 });
750 edges.push(GraphEdge {
751 from: package.id.clone(),
752 to: trigger.id.clone(),
753 role: String::from("package_trigger"),
754 });
755 edges.push(GraphEdge {
756 from: trigger.id.clone(),
757 to: trigger.handler_id.clone(),
758 role: String::from("trigger_handler"),
759 });
760 }
761 for permission in &package.permissions {
762 nodes.push(GraphNode {
763 id: permission.id.clone(),
764 label: format!("Package permission: {}", permission.capability),
765 links_notation: format!("{} effect={}", permission.id, permission.effect),
766 });
767 edges.push(GraphEdge {
768 from: package.id.clone(),
769 to: permission.id.clone(),
770 role: String::from("package_permission"),
771 });
772 }
773 }
774 (nodes, edges)
775}
776
777fn required_child<'a>(
778 node: &'a LinoNode,
779 name: &'static str,
780) -> Result<&'a str, PackageImportError> {
781 let value = node.find_child_value(name);
782 if value.is_empty() {
783 Err(PackageImportError::MissingField(name))
784 } else {
785 Ok(value)
786 }
787}
788
789fn link_record(
790 record_id: &str,
791 record_type: &str,
792 subtype: &str,
793 source_id: &str,
794 fields: &[(&str, &str)],
795) -> LinkRecord {
796 let mut links = Vec::new();
797 push_doublet(&mut links, record_id, "Type");
798 push_doublet(&mut links, "Type", record_type);
799 push_doublet(&mut links, record_type, "SubType");
800 push_doublet(&mut links, "SubType", subtype);
801 push_doublet(&mut links, subtype, "Value");
802 push_doublet(&mut links, record_id, source_id);
803 push_field(
804 &mut links,
805 record_id,
806 "schema_version",
807 KNOWLEDGE_SCHEMA_VERSION,
808 );
809 for (key, value) in fields {
810 push_field(&mut links, record_id, key, value);
811 }
812 LinkRecord {
813 stable_id: record_id.to_owned(),
814 schema_version: KNOWLEDGE_SCHEMA_VERSION.to_owned(),
815 record_type: record_type.to_owned(),
816 source_id: source_id.to_owned(),
817 links,
818 }
819}
820
821fn push_field(links: &mut Vec<DoubletLink>, record_id: &str, key: &str, value: &str) {
822 if value.is_empty() {
823 return;
824 }
825 let field = format!("field:{key}");
826 let field_value = format!("value:{value}");
827 push_doublet(links, record_id, &field);
828 push_doublet(links, &field, &field_value);
829}
830
831fn push_doublet(links: &mut Vec<DoubletLink>, from: &str, to: &str) {
832 links.push(DoubletLink {
833 index: stable_id("doublet", &format!("{from}->{to}")),
834 from: from.to_owned(),
835 to: to.to_owned(),
836 });
837}