1use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::capability::benchmark::{BenchmarkCases, CaseContext, NamedCase};
13use crate::capability::handler::{CapabilityError, Context};
14use crate::capability::model::{CachePolicy, Exposure, McpExposure, McpResource, Stability};
15use crate::capability::module::ModuleDescriptor;
16use crate::model::Severity;
17use crate::why::{Area, Audience, Catalogue, Diagnosis, Facets, Finding, Moment, Query, STABLE};
18use crate::{capability, module};
19
20use super::{get, mcp, Empty};
21
22pub const WHY_URI: &str = "majordomus://why";
24pub const AUDIENCES_URI: &str = "majordomus://why/audiences";
26pub const AREAS_URI: &str = "majordomus://why/areas";
28
29pub const SCHEMA: &str = "majordomus/why-catalogue/v1";
31
32#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
36pub struct MomentSummary {
37 pub id: String,
39 pub title: String,
41 pub label: String,
43 pub hook: String,
45 pub summary: String,
47 pub status: String,
49 pub severity: String,
51 pub frequency: String,
53 pub weight: u32,
55 pub featured: bool,
57 pub audiences: Vec<String>,
59 pub areas: Vec<String>,
61 pub tags: Vec<String>,
63 pub route: String,
65 pub signals: usize,
67 pub examples: usize,
69}
70
71impl MomentSummary {
72 fn of(m: &Moment) -> Self {
73 MomentSummary {
74 id: m.id.clone(),
75 title: m.title.clone(),
76 label: m.label().to_string(),
77 hook: m.hook.clone(),
78 summary: m.summary.clone(),
79 status: m.status.clone(),
80 severity: m.severity.clone(),
81 frequency: m.frequency.clone(),
82 weight: m.weight,
83 featured: m.featured,
84 audiences: m.audiences.clone(),
85 areas: m.areas.clone(),
86 tags: m.tags.clone(),
87 route: m.route.clone(),
88 signals: m.signals.len(),
89 examples: m.examples.len(),
90 }
91 }
92}
93
94#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
97pub struct Counts {
98 pub moments: usize,
100 pub moments_all: usize,
102 pub audiences: usize,
104 pub areas: usize,
106 pub signals: usize,
108 pub examples: usize,
110}
111
112#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
114pub struct CatalogueView {
115 pub schema: String,
117 pub fingerprint: String,
120 pub counts: Counts,
122 pub moments: Vec<MomentSummary>,
124 pub audiences: Vec<AudienceView>,
126 pub areas: Vec<AreaView>,
128 pub facets: Facets,
130}
131
132#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
134pub struct AudienceView {
135 #[serde(flatten)]
137 pub audience: Audience,
138 pub moments: Vec<String>,
140 pub count: usize,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
146pub struct AreaView {
147 #[serde(flatten)]
149 pub area: Area,
150 pub moments: Vec<String>,
152 pub count: usize,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
158pub struct MomentDetail {
159 #[serde(flatten)]
161 pub moment: Moment,
162 pub responsibilities: Vec<String>,
164 pub backlinks: Vec<String>,
166 pub similar: Vec<String>,
169 pub related_detail: Vec<MomentSummary>,
171 pub neighbours: Vec<String>,
176}
177
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
180pub struct AudienceList {
181 pub count: usize,
183 pub audiences: Vec<AudienceView>,
185}
186
187#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
189pub struct AreaList {
190 pub count: usize,
192 pub areas: Vec<AreaView>,
194}
195
196#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
198pub struct ValidationReport {
199 pub valid: bool,
201 pub errors: usize,
204 pub warnings: usize,
206 pub counts: Counts,
208 pub findings: Vec<Finding>,
210}
211
212impl BenchmarkCases for Query {
215 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
216 let mut cases = vec![NamedCase::new("all", Query::default())];
217 if let Some(a) = ctx
218 .index
219 .objects
220 .iter()
221 .find(|o| o.kind == crate::why::AUDIENCE)
222 {
223 cases.push(NamedCase::new(
224 "by-audience",
225 Query {
226 audience: Some(a.identity.clone()),
227 ..Query::default()
228 },
229 ));
230 }
231 cases.push(NamedCase::new(
232 "search",
233 Query {
234 q: Some("context".into()),
235 ..Query::default()
236 },
237 ));
238 cases
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
244#[serde(deny_unknown_fields)]
245pub struct MomentInput {
246 pub id: String,
248}
249
250impl BenchmarkCases for MomentInput {
251 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
252 ctx.index
253 .objects
254 .iter()
255 .find(|o| o.kind == crate::why::MOMENT)
256 .map(|o| {
257 vec![NamedCase::new(
258 "first-moment",
259 MomentInput {
260 id: o.identity.clone(),
261 },
262 )]
263 })
264 .unwrap_or_else(|| {
269 vec![NamedCase::new(
270 "absent",
271 MomentInput {
272 id: "absent".into(),
273 },
274 )]
275 })
276 }
277}
278
279#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
286#[serde(deny_unknown_fields)]
287pub struct DiagnoseInput {
288 #[serde(default)]
291 pub signals: String,
292}
293
294impl DiagnoseInput {
295 pub fn names(&self) -> Vec<String> {
297 self.signals
298 .split(',')
299 .map(str::trim)
300 .filter(|s| !s.is_empty())
301 .map(str::to_string)
302 .collect()
303 }
304}
305
306impl BenchmarkCases for DiagnoseInput {
307 fn benchmark_cases(ctx: &CaseContext<'_>) -> Vec<NamedCase<Self>> {
308 let ids: Vec<String> = ctx
309 .index
310 .objects
311 .iter()
312 .filter(|o| o.kind == crate::why::MOMENT)
313 .take(3)
314 .map(|o| o.identity.clone())
315 .collect();
316 if ids.is_empty() {
317 return vec![NamedCase::new(
320 "nothing",
321 DiagnoseInput {
322 signals: String::new(),
323 },
324 )];
325 }
326 vec![NamedCase::new(
327 "three-moments",
328 DiagnoseInput {
329 signals: ids.join(","),
330 },
331 )]
332 }
333}
334
335fn counts(c: &Catalogue) -> Counts {
338 let public: Vec<&Moment> = c.all().iter().filter(|m| m.status == STABLE).collect();
339 Counts {
340 moments: public.len(),
341 moments_all: c.all().len(),
342 audiences: c.audiences().iter().filter(|a| a.status == STABLE).count(),
343 areas: c.areas().iter().filter(|a| a.status == STABLE).count(),
344 signals: public.iter().map(|m| m.signals.len()).sum(),
345 examples: public.iter().map(|m| m.examples.len()).sum(),
346 }
347}
348
349fn audience_views(c: &Catalogue) -> Vec<AudienceView> {
350 c.audiences()
351 .iter()
352 .map(|a| {
353 let moments: Vec<String> = c
354 .naming("audience", &a.id)
355 .into_iter()
356 .filter(|m| m.status == STABLE)
357 .map(|m| m.id.clone())
358 .collect();
359 AudienceView {
360 audience: a.clone(),
361 count: moments.len(),
362 moments,
363 }
364 })
365 .collect()
366}
367
368fn area_views(c: &Catalogue) -> Vec<AreaView> {
369 c.areas()
370 .iter()
371 .map(|a| {
372 let moments: Vec<String> = c
373 .naming("area", &a.id)
374 .into_iter()
375 .filter(|m| m.status == STABLE)
376 .map(|m| m.id.clone())
377 .collect();
378 AreaView {
379 area: a.clone(),
380 count: moments.len(),
381 moments,
382 }
383 })
384 .collect()
385}
386
387fn why_list(ctx: &Context, input: Query) -> Result<CatalogueView, CapabilityError> {
388 let c = &ctx.why;
389 for (field, value, known) in [
390 (
391 "audience",
392 &input.audience,
393 c.audiences()
394 .iter()
395 .map(|a| a.id.clone())
396 .collect::<Vec<_>>(),
397 ),
398 (
399 "area",
400 &input.area,
401 c.areas().iter().map(|a| a.id.clone()).collect::<Vec<_>>(),
402 ),
403 ] {
404 if let Some(v) = value {
405 if !known.contains(v) {
406 return Err(CapabilityError::InvalidInput(format!(
407 "{field} '{v}' is not one this catalogue has: {}",
408 known.join(", ")
409 )));
410 }
411 }
412 }
413 Ok(CatalogueView {
414 schema: SCHEMA.into(),
415 fingerprint: c.fingerprint().into(),
416 counts: counts(c),
417 moments: c
418 .select(&input)
419 .into_iter()
420 .map(MomentSummary::of)
421 .collect(),
422 audiences: audience_views(c),
423 areas: area_views(c),
424 facets: c.facets(),
425 })
426}
427
428fn why_moment(ctx: &Context, input: MomentInput) -> Result<MomentDetail, CapabilityError> {
429 let c = &ctx.why;
430 let m = c.moment(&input.id).ok_or_else(|| {
431 CapabilityError::NotFound(format!(
432 "no moment '{}'; `why.list` names every one this repository holds",
433 input.id
434 ))
435 })?;
436 let mut scored: Vec<(usize, &Moment)> = c
440 .all()
441 .iter()
442 .filter(|o| o.id != m.id && o.status == STABLE && !m.related.contains(&o.id))
443 .map(|o| {
444 let shared = o.areas.iter().filter(|a| m.areas.contains(a)).count()
445 + o.audiences
446 .iter()
447 .filter(|a| m.audiences.contains(a))
448 .count()
449 + o.tags.iter().filter(|t| m.tags.contains(t)).count();
450 (shared, o)
451 })
452 .filter(|(n, _)| *n > 0)
453 .collect();
454 scored.sort_by(|a, b| {
455 b.0.cmp(&a.0)
456 .then(a.1.weight.cmp(&b.1.weight))
457 .then(a.1.id.cmp(&b.1.id))
458 });
459 let similar: Vec<String> = scored
460 .into_iter()
461 .take(6)
462 .map(|(_, o)| o.id.clone())
463 .collect();
464 let backlinks = c.backlinks(&m.id).to_vec();
465 let mut neighbours: Vec<String> = Vec::new();
468 for id in m
469 .related
470 .iter()
471 .chain(backlinks.iter())
472 .chain(similar.iter())
473 {
474 if id != &m.id && !neighbours.contains(id) {
475 neighbours.push(id.clone());
476 }
477 }
478 Ok(MomentDetail {
479 moment: m.clone(),
480 responsibilities: c.responsibilities(&m.id).to_vec(),
481 backlinks,
482 similar,
483 neighbours,
484 related_detail: m
485 .related
486 .iter()
487 .filter_map(|r| c.moment(r))
488 .map(MomentSummary::of)
489 .collect(),
490 })
491}
492
493fn why_audiences(ctx: &Context, _: Empty) -> Result<AudienceList, CapabilityError> {
494 let audiences = audience_views(&ctx.why);
495 Ok(AudienceList {
496 count: audiences.len(),
497 audiences,
498 })
499}
500
501fn why_areas(ctx: &Context, _: Empty) -> Result<AreaList, CapabilityError> {
502 let areas = area_views(&ctx.why);
503 Ok(AreaList {
504 count: areas.len(),
505 areas,
506 })
507}
508
509fn why_diagnose(ctx: &Context, input: DiagnoseInput) -> Result<Diagnosis, CapabilityError> {
510 Ok(ctx.why.diagnose(&input.names()))
511}
512
513fn why_validate(ctx: &Context, _: Empty) -> Result<ValidationReport, CapabilityError> {
514 let c = &ctx.why;
515 let errors = c.errors();
516 Ok(ValidationReport {
517 valid: errors == 0,
518 errors,
519 warnings: c
520 .findings()
521 .iter()
522 .filter(|f| f.severity == Severity::Warning)
523 .count(),
524 counts: counts(c),
525 findings: c.findings().to_vec(),
526 })
527}
528
529pub fn module() -> ModuleDescriptor {
531 module! {
532 id: "why",
533 title: "Why",
534 description: "The operational failure modes this tool is a response to: the moments a reader recognises, the audiences that recognise them, the areas they fall under, and what a reader's own symptoms imply. Every entry is a file under the layer's why section; nothing here holds a list, and a moment added there is answered by all of these without a registration anywhere.",
535 stability: Stability::BehaviorallyVerified,
536 capabilities: [
537 capability! {
538 id: "why.list",
539 title: "The catalogue",
540 description: "Every operational moment this repository holds, narrowed by any of the facets the catalogue itself reports, with the audiences, the areas, the derived filters and the counts. The default is the public catalogue; pass status=any for the drafts too.",
541 input: Query,
542 output: CatalogueView,
543 stability: Stability::BehaviorallyVerified,
544 exposure: Exposure {
545 mcp: Some(McpExposure {
546 tool: Some("majordomus_why".into()),
547 resource: Some(McpResource { uri: WHY_URI.into(), name: "why".into() }),
548 }),
549 http: get("/api/v1/why"),
550 cli: Some(crate::capability::CliExposure { path: vec!["why".into(), "list".into()] }),
551 },
552 tags: ["why", "catalogue"],
553 cache: CachePolicy::Process { max_entries: 32, ttl_seconds: None },
554 handler: why_list,
555 },
556 capability! {
557 id: "why.moment",
558 title: "One moment",
559 description: "One operational moment in full: what it looks like, why it happens, what it costs, what this tool does about it, and every relation derived from its metadata — the responsibilities its claims belong to, the moments that name it, and the moments nearest it by shared area, audience and tag.",
560 input: MomentInput,
561 output: MomentDetail,
562 stability: Stability::BehaviorallyVerified,
563 exposure: Exposure {
564 mcp: mcp("majordomus_why_moment"),
565 http: get("/api/v1/why/moment"),
566 cli: Some(crate::capability::CliExposure { path: vec!["why".into(), "show".into()] }),
567 },
568 tags: ["why"],
569 cache: CachePolicy::Process { max_entries: 64, ttl_seconds: None },
570 handler: why_moment,
571 },
572 capability! {
573 id: "why.audiences",
574 title: "Audiences",
575 description: "Every audience the catalogue declares, each with the public moments that name it. Membership is derived from the moments and is never listed in an audience's own file.",
576 input: Empty,
577 output: AudienceList,
578 stability: Stability::BehaviorallyVerified,
579 exposure: Exposure {
580 mcp: Some(McpExposure {
581 tool: Some("majordomus_why_audiences".into()),
582 resource: Some(McpResource { uri: AUDIENCES_URI.into(), name: "why-audiences".into() }),
583 }),
584 http: get("/api/v1/why/audiences"),
585 cli: Some(crate::capability::CliExposure { path: vec!["why".into(), "audiences".into()] }),
586 },
587 tags: ["why", "catalogue"],
588 cache: CachePolicy::Process { max_entries: 4, ttl_seconds: None },
589 handler: why_audiences,
590 },
591 capability! {
592 id: "why.areas",
593 title: "Operational areas",
594 description: "Every operational area the catalogue declares, each with the public moments that fall under it. Membership is derived from the moments and is never listed in an area's own file.",
595 input: Empty,
596 output: AreaList,
597 stability: Stability::BehaviorallyVerified,
598 exposure: Exposure {
599 mcp: Some(McpExposure {
600 tool: Some("majordomus_why_areas".into()),
601 resource: Some(McpResource { uri: AREAS_URI.into(), name: "why-areas".into() }),
602 }),
603 http: get("/api/v1/why/areas"),
604 cli: Some(crate::capability::CliExposure { path: vec!["why".into(), "areas".into()] }),
605 },
606 tags: ["why", "catalogue"],
607 cache: CachePolicy::Process { max_entries: 4, ttl_seconds: None },
608 handler: why_areas,
609 },
610 capability! {
611 id: "why.diagnose",
612 title: "Diagnose a selection",
613 description: "What a reader's own symptoms imply: the moments the selection resolves to, the operational areas and audiences they weigh towards, and the capabilities, commands, claims, rules and use cases that answer them — each carrying the moments that produced it. Counting, not inference: there is no weighting and no percentage.",
614 input: DiagnoseInput,
615 output: Diagnosis,
616 stability: Stability::BehaviorallyVerified,
617 exposure: Exposure {
618 mcp: mcp("majordomus_why_diagnose"),
619 http: get("/api/v1/why/diagnose"),
620 cli: Some(crate::capability::CliExposure { path: vec!["why".into(), "diagnose".into()] }),
621 },
622 tags: ["why", "diagnostics"],
623 cache: CachePolicy::Process { max_entries: 32, ttl_seconds: None },
624 handler: why_diagnose,
625 },
626 capability! {
627 id: "why.validate",
628 title: "Validate the catalogue",
629 description: "Every finding over the catalogue: a reference that resolves to nothing, with the nearest candidate; a duplicate identity; a file name that disagrees with its id; and a public record that does not meet the floor its status promises. Errors make the catalogue invalid; warnings do not.",
630 input: Empty,
631 output: ValidationReport,
632 stability: Stability::BehaviorallyVerified,
633 exposure: Exposure {
634 mcp: mcp("majordomus_why_validate"),
635 http: get("/api/v1/why/validate"),
636 cli: Some(crate::capability::CliExposure { path: vec!["why".into(), "validate".into()] }),
637 },
638 tags: ["why", "introspection"],
639 cache: CachePolicy::Process { max_entries: 2, ttl_seconds: None },
640 handler: why_validate,
641 },
642 ],
643 }
644}
645
646#[cfg(test)]
647mod tests {
648 use super::*;
649
650 #[test]
656 fn the_declaration_yields_the_projections_it_claims() {
657 let m = module();
658 assert_eq!(m.id.as_str(), "why");
659 let expected: &[(&str, &str, &str)] = &[
660 ("why.list", "majordomus_why", "/api/v1/why"),
661 ("why.moment", "majordomus_why_moment", "/api/v1/why/moment"),
662 (
663 "why.audiences",
664 "majordomus_why_audiences",
665 "/api/v1/why/audiences",
666 ),
667 ("why.areas", "majordomus_why_areas", "/api/v1/why/areas"),
668 (
669 "why.diagnose",
670 "majordomus_why_diagnose",
671 "/api/v1/why/diagnose",
672 ),
673 (
674 "why.validate",
675 "majordomus_why_validate",
676 "/api/v1/why/validate",
677 ),
678 ];
679 let ids: Vec<&str> = m
680 .capabilities
681 .iter()
682 .map(|e| e.capability.id.as_str())
683 .collect();
684 let want: Vec<&str> = expected.iter().map(|(id, _, _)| *id).collect();
685 assert_eq!(
686 ids, want,
687 "the module declares a different set of capabilities"
688 );
689 for (executable, (id, tool, path)) in m.capabilities.iter().zip(expected) {
690 let exposure = &executable.capability.exposure;
691 assert_eq!(
692 exposure.mcp.as_ref().and_then(|m| m.tool.as_deref()),
693 Some(*tool),
694 "{id} lost or renamed its MCP tool"
695 );
696 assert_eq!(
697 exposure.http.as_ref().map(|h| h.path.as_str()),
698 Some(*path),
699 "{id} lost or renamed its HTTP route"
700 );
701 }
702 }
703}