Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,20 @@

All notable changes to this project will be documented in this file. See [commit-and-tag-version](https://github.com/absolute-version/commit-and-tag-version) for commit guidelines.

## [2.1.0](///compare/v2.0.1...v2.1.0) (2026-03-27)


### Features

* expose precise cache-layer metrics a8a6929

## [2.1.0](https://github.com/SolverForge/solverforge-maps/compare/v2.0.1...v2.1.0) (2026-03-26)

### Features

* expose precise cache-layer metrics for `load_or_fetch`
* run live external-service integration checks from local Makefile validation

## [2.0.1](https://github.com/SolverForge/solverforge-maps/compare/v2.0.0...v2.0.1) (2026-03-21)

### Chores
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "solverforge-maps"
version = "2.0.1"
version = "2.1.0"
edition = "2021"
description = "Generic map and routing utilities for VRP and similar problems"
license = "Apache-2.0"
Expand Down
15 changes: 11 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ VERSION := $(shell grep -m1 '^version' Cargo.toml | sed 's/version = "\(.*\)"/\1
RUST_VERSION := 1.80+

# ============== Phony Targets ==============
.PHONY: banner help build build-release test test-quick test-doc test-unit test-one \
.PHONY: banner help build build-release test test-live test-quick test-doc test-unit test-one \
lint fmt fmt-check clippy ci-local pre-release version bump-patch bump-minor bump-major \
bump-dry publish-dry publish clean watch

Expand Down Expand Up @@ -68,8 +68,13 @@ test: banner
@cargo test && \
printf "\n$(GREEN)$(CHECK) All tests passed$(RESET)\n\n" || \
(printf "\n$(RED)$(CROSS) Tests failed$(RESET)\n\n" && exit 1)
@printf "$(ARROW) $(BOLD)Visual test output:$(RESET)\n"
@cargo test visual --quiet -- --nocapture 2>/dev/null
@$(MAKE) test-live --no-print-directory

test-live:
@printf "$(PROGRESS) Running live integration tests...\n"
@SOLVERFORGE_RUN_LIVE_TESTS=1 cargo test --test live_integration -- --nocapture && \
printf "$(GREEN)$(CHECK) Live integration tests passed$(RESET)\n" || \
(printf "$(RED)$(CROSS) Live integration tests failed$(RESET)\n" && exit 1)

test-quick: banner
@printf "$(CYAN)$(BOLD)╔══════════════════════════════════════╗$(RESET)\n"
Expand Down Expand Up @@ -180,6 +185,7 @@ pre-release: banner
@$(MAKE) clippy --no-print-directory
@printf "$(PROGRESS) Running full test suite...\n"
@cargo test --quiet && printf "$(GREEN)$(CHECK) All tests passed$(RESET)\n"
@$(MAKE) test-live --no-print-directory
@printf "\n$(GREEN)$(BOLD)$(CHECK) Ready for release v$(VERSION)$(RESET)\n\n"

# ============== Publishing ==============
Expand Down Expand Up @@ -231,6 +237,7 @@ help: banner
@/bin/echo -e ""
@/bin/echo -e "$(CYAN)$(BOLD)Test Commands:$(RESET)"
@/bin/echo -e " $(GREEN)make test$(RESET) - Run all tests"
@/bin/echo -e " $(GREEN)make test-live$(RESET) - Run live integration tests against external services"
@/bin/echo -e " $(GREEN)make test-quick$(RESET) - Run doctests + unit + integration tests (fast)"
@/bin/echo -e " $(GREEN)make test-doc$(RESET) - Run doctests only"
@/bin/echo -e " $(GREEN)make test-unit$(RESET) - Run unit tests only"
Expand All @@ -244,7 +251,7 @@ help: banner
@/bin/echo -e ""
@/bin/echo -e "$(CYAN)$(BOLD)CI & Quality:$(RESET)"
@/bin/echo -e " $(GREEN)make ci-local$(RESET) - $(YELLOW)$(BOLD)Simulate GitHub Actions CI locally$(RESET)"
@/bin/echo -e " $(GREEN)make pre-release$(RESET) - Run all validation checks"
@/bin/echo -e " $(GREEN)make pre-release$(RESET) - Run all validation checks, including live integration tests"
@/bin/echo -e ""
@/bin/echo -e "$(CYAN)$(BOLD)Version Management:$(RESET)"
@/bin/echo -e " $(GREEN)make version$(RESET) - Show current version"
Expand Down
39 changes: 37 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ Generic map and routing utilities for Vehicle Routing Problems (VRP) and similar

```toml
[dependencies]
solverforge-maps = "1.0"
solverforge-maps = "2.1"
tokio = { version = "1", features = ["full"] }
```

Expand Down Expand Up @@ -381,7 +381,11 @@ println!("Cached networks: {}", stats.networks_cached);
println!("Total nodes: {}", stats.total_nodes);
println!("Total edges: {}", stats.total_edges);
println!("Memory: {} bytes", stats.memory_bytes);
println!("Hits: {}, Misses: {}", stats.hits, stats.misses);
println!("Load requests: {}", stats.load_requests);
println!("Memory hits: {}", stats.memory_hits);
println!("Disk hits: {}", stats.disk_hits);
println!("Network fetches: {}", stats.network_fetches);
println!("In-flight waits: {}", stats.in_flight_waits);

// List cached regions
let regions: Vec<BoundingBox> = RoadNetwork::cached_regions().await;
Expand All @@ -394,6 +398,11 @@ let evicted: bool = RoadNetwork::evict(&bbox).await;
RoadNetwork::clear_cache().await;
```

`CacheStats` reports outcome-based cache metrics for `load_or_fetch` requests.
The old aggregate `hits` / `misses` counters are intentionally not exposed
because they did not distinguish memory, disk, network, or contention outcomes
accurately.

---

### Progress Reporting
Expand Down Expand Up @@ -580,6 +589,32 @@ RoadNetwork::clear_cache().await;

---

## Testing

Hermetic tests run by default:

```bash
cargo test
```

Live external-service integration tests are enabled explicitly for local runs:

```bash
make test-live
```

`make test` and `make pre-release` include the live suite locally. GitHub CI
does not set `SOLVERFORGE_RUN_LIVE_TESTS=1`, so workflow runs stay self-contained
and skip external-service checks by policy.

For manual network visualization against live Overpass data, run:

```bash
cargo run --example live_network_visualization
```

---

## License

Apache-2.0
85 changes: 85 additions & 0 deletions examples/live_network_visualization.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
use solverforge_maps::{BoundingBox, NetworkConfig, RoadNetwork};
use textplots::{Chart, Plot, Shape};

struct Location {
name: &'static str,
bbox: BoundingBox,
}

fn locations() -> Vec<Location> {
vec![
Location {
name: "Philadelphia (City Hall area)",
bbox: BoundingBox::new(39.946, -75.174, 39.962, -75.150),
},
Location {
name: "Dragoncello (Poggio Rusco, IT)",
bbox: BoundingBox::new(44.978, 11.095, 44.986, 11.108),
},
Location {
name: "Clusone (Lombardy, IT)",
bbox: BoundingBox::new(45.882, 9.940, 45.895, 9.960),
},
]
}

fn plot_network(name: &str, network: &RoadNetwork, bbox: &BoundingBox) {
let nodes: Vec<(f64, f64)> = network.nodes_iter().collect();
let edges: Vec<(usize, usize, f64, f64)> = network.edges_iter().collect();

if nodes.is_empty() {
println!("\n{}: No data", name);
return;
}

let mut segments: Vec<(f32, f32)> = Vec::new();
let mut seen = std::collections::HashSet::new();
for &(from, to, _, _) in &edges {
let key = (from.min(to), from.max(to));
if seen.insert(key) && from < nodes.len() && to < nodes.len() {
let (lat1, lng1) = nodes[from];
let (lat2, lng2) = nodes[to];
segments.push((lng1 as f32, lat1 as f32));
segments.push((lng2 as f32, lat2 as f32));
segments.push((f32::NAN, f32::NAN));
}
}

let intersections: Vec<(f32, f32)> = nodes
.iter()
.map(|(lat, lng)| (*lng as f32, *lat as f32))
.collect();

println!("\n{}", name);
println!(
"{} nodes, {} edges",
network.node_count(),
network.edge_count()
);

let x_min = bbox.min_lng as f32;
let x_max = bbox.max_lng as f32;

Chart::new(180, 60, x_min, x_max)
.lineplot(&Shape::Lines(&segments))
.lineplot(&Shape::Points(&intersections))
.nice();
}

#[tokio::main]
async fn main() {
let config = NetworkConfig::default();

for loc in locations() {
print!("\nFetching {}...", loc.name);
match RoadNetwork::load_or_fetch(&loc.bbox, &config, None).await {
Ok(network_ref) => {
println!(" done");
plot_network(loc.name, &network_ref, &loc.bbox);
}
Err(e) => {
println!(" failed: {}", e);
}
}
}
}
61 changes: 40 additions & 21 deletions src/routing/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,11 @@ pub const CACHE_VERSION: u32 = 5;

static NETWORK_CACHE: OnceLock<RwLock<HashMap<String, RoadNetwork>>> = OnceLock::new();
static IN_FLIGHT_LOADS: OnceLock<Mutex<HashMap<String, Arc<Mutex<()>>>>> = OnceLock::new();
static CACHE_HITS: AtomicU64 = AtomicU64::new(0);
static CACHE_MISSES: AtomicU64 = AtomicU64::new(0);
static LOAD_REQUESTS: AtomicU64 = AtomicU64::new(0);
static MEMORY_HITS: AtomicU64 = AtomicU64::new(0);
static DISK_HITS: AtomicU64 = AtomicU64::new(0);
static NETWORK_FETCHES: AtomicU64 = AtomicU64::new(0);
static IN_FLIGHT_WAITS: AtomicU64 = AtomicU64::new(0);

pub(crate) fn cache() -> &'static RwLock<HashMap<String, RoadNetwork>> {
NETWORK_CACHE.get_or_init(|| RwLock::new(HashMap::new()))
Expand All @@ -28,12 +31,33 @@ pub(crate) fn in_flight_loads() -> &'static Mutex<HashMap<String, Arc<Mutex<()>>
IN_FLIGHT_LOADS.get_or_init(|| Mutex::new(HashMap::new()))
}

pub(crate) fn record_hit() {
CACHE_HITS.fetch_add(1, Ordering::Relaxed);
pub(crate) fn record_load_request() {
LOAD_REQUESTS.fetch_add(1, Ordering::Relaxed);
}

pub(crate) fn record_miss() {
CACHE_MISSES.fetch_add(1, Ordering::Relaxed);
pub(crate) fn record_memory_hit() {
MEMORY_HITS.fetch_add(1, Ordering::Relaxed);
}

pub(crate) fn record_disk_hit() {
DISK_HITS.fetch_add(1, Ordering::Relaxed);
}

pub(crate) fn record_network_fetch() {
NETWORK_FETCHES.fetch_add(1, Ordering::Relaxed);
}

pub(crate) fn record_in_flight_wait() {
IN_FLIGHT_WAITS.fetch_add(1, Ordering::Relaxed);
}

#[cfg(test)]
pub(crate) fn reset_cache_metrics() {
LOAD_REQUESTS.store(0, Ordering::Relaxed);
MEMORY_HITS.store(0, Ordering::Relaxed);
DISK_HITS.store(0, Ordering::Relaxed);
NETWORK_FETCHES.store(0, Ordering::Relaxed);
IN_FLIGHT_WAITS.store(0, Ordering::Relaxed);
}

#[derive(Debug, Clone)]
Expand All @@ -42,19 +66,11 @@ pub struct CacheStats {
pub total_nodes: usize,
pub total_edges: usize,
pub memory_bytes: usize,
pub hits: u64,
pub misses: u64,
}

impl CacheStats {
pub fn hit_ratio(&self) -> f64 {
let total = self.hits + self.misses;
if total == 0 {
0.0
} else {
self.hits as f64 / total as f64
}
}
pub load_requests: u64,
pub memory_hits: u64,
pub disk_hits: u64,
pub network_fetches: u64,
pub in_flight_waits: u64,
Comment on lines +69 to +73
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve CacheStats compatibility in 2.x releases

This commit removes the public CacheStats surface (hits, misses, and hit_ratio) and replaces it with new fields, which is a source-compatible break for existing 2.x consumers that currently compile against those members. Any downstream code reading stats.hits or calling stats.hit_ratio() will fail to build after updating. To avoid breaking users, keep deprecated compatibility accessors/fields for the 2.x line (or publish this as a major-version API break).

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This API change is intentional. The old hits / misses / hit_ratio() surface was materially misleading, so we do not want to preserve it in the new metrics model. We are treating this as an acceptable breaking change for this release line rather than carrying forward compatibility accessors that keep the ambiguous semantics alive.

}

/// RAII guard providing zero-cost access to a cached RoadNetwork.
Expand Down Expand Up @@ -135,8 +151,11 @@ impl RoadNetwork {
total_nodes,
total_edges,
memory_bytes,
hits: CACHE_HITS.load(Ordering::Relaxed),
misses: CACHE_MISSES.load(Ordering::Relaxed),
load_requests: LOAD_REQUESTS.load(Ordering::Relaxed),
memory_hits: MEMORY_HITS.load(Ordering::Relaxed),
disk_hits: DISK_HITS.load(Ordering::Relaxed),
network_fetches: NETWORK_FETCHES.load(Ordering::Relaxed),
in_flight_waits: IN_FLIGHT_WAITS.load(Ordering::Relaxed),
}
}

Expand Down
Loading
Loading