Skip to content

Commit ddefe25

Browse files
userFRMclaude
andauthored
feat: add reconstruction scheduler, validation improvements, and doc fixes (#34)
- Add reconstruction module (paper Section B.2.2): topological execution order with area-coherent batching via `schedule_reconstruction()` - Expose as CLI command (`reconstruct-plan`) and MCP tool (`reconstruct_plan`) - Add always-on validation in `submit_routing_decisions`: decisions must target pending entities with valid 3-level paths in existing hierarchy - Add always-on 3-level path validation in `submit_hierarchy` - Replace blind pending-routing clear in `update_rpg` with smart reconciliation that preserves valid entries - Add helper utilities: `is_three_level_hierarchy_path()`, `hierarchy_path_exists()`, `directory` entity type alias - Fix paper_fidelity.md: correct wrong citations, section refs, version, language count; add reconstruction scheduling section Closes #33 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c6d48c4 commit ddefe25

9 files changed

Lines changed: 773 additions & 27 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,10 @@ rpg-encoder info
191191
rpg-encoder update
192192
rpg-encoder update --since abc1234
193193

194+
# Paper-style reconstruction schedule (topological + coherent batches)
195+
rpg-encoder reconstruct-plan --max-batch-size 8 --format text
196+
rpg-encoder reconstruct-plan --format json
197+
194198
# Pre-commit hook (auto-updates graph on every commit)
195199
rpg-encoder hook install
196200
```

crates/rpg-cli/src/main.rs

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,21 @@ enum Commands {
103103
since: Option<String>,
104104
},
105105

106+
/// Build a paper-style reconstruction execution plan (topological + batches)
107+
ReconstructPlan {
108+
/// Maximum number of entities per execution batch
109+
#[arg(long, default_value_t = 8)]
110+
max_batch_size: usize,
111+
112+
/// Output format: text, json
113+
#[arg(short, long, default_value = "text")]
114+
format: String,
115+
116+
/// Include file-level Module entities in the schedule
117+
#[arg(long, default_value_t = false)]
118+
include_modules: bool,
119+
},
120+
106121
/// Validate graph integrity (check for orphans, dangling edges, etc.)
107122
Validate,
108123

@@ -166,6 +181,11 @@ fn main() -> Result<()> {
166181
Commands::Info => cmd_info(&project_root),
167182
Commands::Export { format } => cmd_export(&project_root, &format),
168183
Commands::Diff { since } => cmd_diff(&project_root, since),
184+
Commands::ReconstructPlan {
185+
max_batch_size,
186+
format,
187+
include_modules,
188+
} => cmd_reconstruct_plan(&project_root, max_batch_size, &format, include_modules),
169189
Commands::Validate => cmd_validate(&project_root),
170190
Commands::Hook { action } => cmd_hook(&project_root, &action),
171191
Commands::Serve => {
@@ -813,6 +833,59 @@ fn cmd_diff(project_root: &Path, since: Option<String>) -> Result<()> {
813833
Ok(())
814834
}
815835

836+
fn cmd_reconstruct_plan(
837+
project_root: &Path,
838+
max_batch_size: usize,
839+
format: &str,
840+
include_modules: bool,
841+
) -> Result<()> {
842+
if !rpg_core::storage::rpg_exists(project_root) {
843+
anyhow::bail!("No RPG found. Run `rpg-encoder build` first.");
844+
}
845+
846+
let graph = rpg_core::storage::load(project_root)?;
847+
let plan = rpg_encoder::reconstruction::schedule_reconstruction(
848+
&graph,
849+
rpg_encoder::reconstruction::ReconstructionOptions {
850+
max_batch_size,
851+
include_modules,
852+
},
853+
);
854+
855+
match format {
856+
"json" => {
857+
println!("{}", serde_json::to_string_pretty(&plan)?);
858+
}
859+
"text" => {
860+
println!(
861+
"Reconstruction plan: {} entities, {} batches",
862+
plan.topological_order.len(),
863+
plan.batches.len()
864+
);
865+
println!("max_batch_size: {}", max_batch_size.max(1));
866+
println!("include_modules: {}", include_modules);
867+
println!();
868+
869+
for batch in &plan.batches {
870+
println!(
871+
"Batch {} [{}] ({} entities)",
872+
batch.batch_index,
873+
batch.area,
874+
batch.entity_ids.len()
875+
);
876+
for entity_id in &batch.entity_ids {
877+
println!(" - {}", entity_id);
878+
}
879+
}
880+
}
881+
other => {
882+
anyhow::bail!("Unknown format: {}. Use 'text' or 'json'.", other);
883+
}
884+
}
885+
886+
Ok(())
887+
}
888+
816889
fn cmd_validate(project_root: &Path) -> Result<()> {
817890
if !rpg_core::storage::rpg_exists(project_root) {
818891
anyhow::bail!("No RPG found. Run `rpg-encoder build` first.");

crates/rpg-encoder/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,5 @@ pub mod evolution;
1111
pub mod grounding;
1212
pub mod hierarchy;
1313
pub mod lift;
14+
pub mod reconstruction;
1415
pub mod semantic_lifting;
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
//! Reconstruction planning utilities for paper-style topological execution.
2+
//!
3+
//! The paper's reconstruction setting executes repository units in dependency-safe
4+
//! topological order, then groups adjacent units into semantically coherent batches.
5+
6+
use rpg_core::graph::{EdgeKind, EntityKind, RPGraph};
7+
use serde::{Deserialize, Serialize};
8+
use std::collections::{BTreeSet, HashMap, HashSet};
9+
10+
/// Reconstruction scheduler options.
11+
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
12+
pub struct ReconstructionOptions {
13+
/// Maximum entities per execution batch.
14+
pub max_batch_size: usize,
15+
/// Whether to include file-level Module entities in the schedule.
16+
pub include_modules: bool,
17+
}
18+
19+
impl Default for ReconstructionOptions {
20+
fn default() -> Self {
21+
Self {
22+
max_batch_size: 8,
23+
include_modules: false,
24+
}
25+
}
26+
}
27+
28+
/// A single reconstruction execution batch.
29+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30+
pub struct ReconstructionBatch {
31+
/// 1-based batch index in execution order.
32+
pub batch_index: usize,
33+
/// Dominant top-level functional area for this batch.
34+
pub area: String,
35+
/// Entity IDs in dependency-safe execution order.
36+
pub entity_ids: Vec<String>,
37+
}
38+
39+
/// End-to-end reconstruction plan.
40+
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
41+
pub struct ReconstructionPlan {
42+
/// Global dependency-safe execution order.
43+
pub topological_order: Vec<String>,
44+
/// Execution batches derived from the order.
45+
pub batches: Vec<ReconstructionBatch>,
46+
}
47+
48+
/// Build a dependency-safe topological execution order.
49+
///
50+
/// The dependency edges are represented as `source -> target` where source depends
51+
/// on target. For reconstruction execution we therefore invert traversal to ensure
52+
/// prerequisites (`target`) come before dependents (`source`).
53+
///
54+
/// When cycles exist, remaining nodes are appended in deterministic lexical order.
55+
pub fn build_topological_execution_order(graph: &RPGraph, include_modules: bool) -> Vec<String> {
56+
let nodes: BTreeSet<String> = graph
57+
.entities
58+
.iter()
59+
.filter(|(_, entity)| include_modules || entity.kind != EntityKind::Module)
60+
.map(|(id, _)| id.clone())
61+
.collect();
62+
63+
if nodes.is_empty() {
64+
return Vec::new();
65+
}
66+
67+
let mut indegree: HashMap<String, usize> = nodes
68+
.iter()
69+
.map(|id| (id.clone(), 0usize))
70+
.collect::<HashMap<_, _>>();
71+
let mut dependents: HashMap<String, Vec<String>> = HashMap::new();
72+
let mut seen_edges: HashSet<(String, String)> = HashSet::new();
73+
74+
for edge in &graph.edges {
75+
if !is_dependency_edge(edge.kind) {
76+
continue;
77+
}
78+
if !nodes.contains(&edge.source) || !nodes.contains(&edge.target) {
79+
continue;
80+
}
81+
if edge.source == edge.target {
82+
continue;
83+
}
84+
85+
// prerequisite -> dependent for topological scheduling
86+
let prerequisite = edge.target.clone();
87+
let dependent = edge.source.clone();
88+
89+
if !seen_edges.insert((prerequisite.clone(), dependent.clone())) {
90+
continue;
91+
}
92+
93+
if let Some(v) = indegree.get_mut(&dependent) {
94+
*v += 1;
95+
}
96+
dependents.entry(prerequisite).or_default().push(dependent);
97+
}
98+
99+
let mut ready: BTreeSet<String> = indegree
100+
.iter()
101+
.filter_map(|(id, deg)| if *deg == 0 { Some(id.clone()) } else { None })
102+
.collect();
103+
let mut order = Vec::with_capacity(nodes.len());
104+
105+
while let Some(node) = ready.pop_first() {
106+
order.push(node.clone());
107+
if let Some(nexts) = dependents.get(&node) {
108+
let mut sorted = nexts.clone();
109+
sorted.sort();
110+
sorted.dedup();
111+
for dependent in sorted {
112+
if let Some(d) = indegree.get_mut(&dependent)
113+
&& *d > 0
114+
{
115+
*d -= 1;
116+
if *d == 0 {
117+
ready.insert(dependent);
118+
}
119+
}
120+
}
121+
}
122+
}
123+
124+
// Cycles: append remaining nodes deterministically.
125+
if order.len() < nodes.len() {
126+
let scheduled: HashSet<&str> = order.iter().map(String::as_str).collect();
127+
let mut remaining: Vec<String> = nodes
128+
.iter()
129+
.filter(|id| !scheduled.contains(id.as_str()))
130+
.cloned()
131+
.collect();
132+
remaining.sort();
133+
order.extend(remaining);
134+
}
135+
136+
order
137+
}
138+
139+
/// Build a paper-style reconstruction plan with topological ordering + semantic batching.
140+
pub fn schedule_reconstruction(
141+
graph: &RPGraph,
142+
options: ReconstructionOptions,
143+
) -> ReconstructionPlan {
144+
let max_batch = options.max_batch_size.max(1);
145+
let order = build_topological_execution_order(graph, options.include_modules);
146+
147+
let mut batches = Vec::new();
148+
let mut current_area = String::new();
149+
let mut current_ids: Vec<String> = Vec::new();
150+
151+
for entity_id in &order {
152+
let area = graph
153+
.entities
154+
.get(entity_id)
155+
.map(|e| top_level_area(&e.hierarchy_path))
156+
.unwrap_or_else(|| "unscoped".to_string());
157+
158+
let area_changed = !current_ids.is_empty() && area != current_area;
159+
let size_hit = current_ids.len() >= max_batch;
160+
161+
if area_changed || size_hit {
162+
batches.push(ReconstructionBatch {
163+
batch_index: batches.len() + 1,
164+
area: current_area.clone(),
165+
entity_ids: std::mem::take(&mut current_ids),
166+
});
167+
}
168+
169+
if current_ids.is_empty() {
170+
current_area = area;
171+
}
172+
current_ids.push(entity_id.clone());
173+
}
174+
175+
if !current_ids.is_empty() {
176+
batches.push(ReconstructionBatch {
177+
batch_index: batches.len() + 1,
178+
area: current_area,
179+
entity_ids: current_ids,
180+
});
181+
}
182+
183+
ReconstructionPlan {
184+
topological_order: order,
185+
batches,
186+
}
187+
}
188+
189+
fn is_dependency_edge(kind: EdgeKind) -> bool {
190+
matches!(
191+
kind,
192+
EdgeKind::Imports
193+
| EdgeKind::Invokes
194+
| EdgeKind::Inherits
195+
| EdgeKind::Composes
196+
| EdgeKind::Renders
197+
| EdgeKind::ReadsState
198+
| EdgeKind::WritesState
199+
| EdgeKind::Dispatches
200+
)
201+
}
202+
203+
fn top_level_area(path: &str) -> String {
204+
let trimmed = path.trim();
205+
if trimmed.is_empty() {
206+
return "unscoped".to_string();
207+
}
208+
trimmed
209+
.split('/')
210+
.find(|s| !s.trim().is_empty())
211+
.map(|s| s.to_string())
212+
.unwrap_or_else(|| "unscoped".to_string())
213+
}

0 commit comments

Comments
 (0)