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
5 changes: 2 additions & 3 deletions benchmarks/suites/hg_b_bfs.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,7 @@ void bm_hgl_backward_bfs(benchmark::State& state) {

auto hg = gen_bf_overlapping_chain_hypergraph<Hypergraph>(n_hedges, layer_width, stride);

auto roots = std::views::iota(id_type{0}, static_cast<id_type>(layer_width))
| std::ranges::to<std::vector<id_type>>();
auto roots = std::views::iota(id_type{0}, static_cast<id_type>(layer_width));

for (auto _ : state) {
auto search_tree = hgl::algorithm::backward_bfs(hg, roots);
Expand Down Expand Up @@ -100,7 +99,7 @@ bool incidence_backward_bfs(

auto root_nodes =
roots | std::views::transform([](const id_type root_id) {
return gl::algorithm::search_node<gl::val_t<IncidenceGraph>>{root_id};
return gl::algorithm::root_node<IncidenceGraph>(root_id);
})
| std::ranges::to<std::vector>();

Expand Down
51 changes: 32 additions & 19 deletions docs/gl/algorithms/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,12 @@ The library provides four primary traversal engines.

The true power of the generic templates lies in their callback/predicate hooks. Every iteration of the engine loop rigidly follows a defined sequence. By injecting custom callbacks (or omitting them via the [**empty_callback**](../../cpp-gl/structgl_1_1algorithm_1_1empty__callback.md)), you dictate the algorithm's behavior.

### Execution Flowchart
### Standard Execution Flow (`bfs`, `pfs`, and stateless `dfs`)

For a single popped node in `bfs`, `dfs`, or `pfs`, the execution flow looks exactly like this:
For a single popped node in standard traversal templates, the execution flow looks exactly like this:

1. **`visit_vertex_pred(node)`**
Evaluated immediately after popping the node. If it returns `false`, the node is skipped entirely, and the loop moves to the next node. *(Commonly used for late-rejection of stale elements in Priority Queues).*
Evaluated immediately after popping the node. If it returns `false`, the node is skipped entirely, and the loop moves to the next node. *(Commonly used for late-rejection of stale elements in Priority Queues or filtering already-visited vertices).*

2. **`pre_visit(vertex_id)`**
A state-modification hook executed right before the vertex is officially marked as "visited".
Expand All @@ -45,9 +45,37 @@ For a single popped node in `bfs`, `dfs`, or `pfs`, the execution flow looks exa

If the target was accepted, this hook allows you to construct a custom object to push into the search frontier.

5. **`post_visit(vertex_id)`**
5. **`post_visit(vertex_id)`** *(BFS/PFS only)*
Executed after all adjacent edges have been evaluated and processed.

### True Post-Order Execution (Iterative `dfs`)

In a standard stack-based DFS, nodes are popped and discarded *before* their children are pushed. This makes executing a true post-order callback (after a node's entire subtree has been exhaustively explored) impossible with a naive implementation.

The CPP-GL `dfs` template solves this using a zero-cost abstraction:

- **Stateless Fast-Path:** If you pass an `empty_callback` for the `post_visit` hook, the engine compiles down to the standard execution flow described above, maximizing performance.
- **Stateful Stack-Frame:** If a valid `post_visit` callback is provided, the engine implicitly wraps the search nodes with a `dfs_extension` payload containing an `expanded` boolean flag.

When utilizing the stateful stack, the execution loop shifts to a two-phase lifecycle:

1. **Phase 1 (First Encounter):** The node is popped. Because `expanded == false`, the engine executes `visit_vertex_pred`, `pre_visit`, and `visit`. It then **marks the node as expanded and pushes it back onto the stack**, followed by pushing all of its valid children on top.
2. **Phase 2 (Subtree Exhausted):** Because the parent was pushed beneath its children, it surfaces again only after its entire subtree has been popped and processed. The engine pops it, sees `expanded == true`, and executes the `post_visit` callback.

### Recursive Execution (`r_dfs`)

The recursive DFS template (`r_dfs`) avoids standard container wrappers entirely and maps the generic callback sequence directly to the C++ call stack. Because of the nature of function calls, `r_dfs` achieves true post-order execution naturally without requiring stateful wrapper nodes.

Its execution flow operates as follows:

1. **Entry:** `visit_vertex_pred`, `pre_visit`, and `visit` are executed immediately upon entering the function.
2. **Recurse:** The engine iterates over outgoing edges. If `enqueue_node_pred` accepts a target, the engine immediately calls `r_dfs` nested within the current loop.
3. **Exit:** After the edge loop completes (meaning all recursive child calls have unwound), `post_visit` is naturally executed before the current function frame returns to its caller.

> [!WARNING] Aborting Recursive Searches
>
> The generic generic `abort` mechanisms (like returning `false` from `visit`) do not work the same way in `r_dfs`. Returning from a nested recursive call only unwinds a single stack frame. If you need to instantly terminate a deep `r_dfs` traversal, you must utilize external state (e.g., throwing a custom exception or checking a global cancellation flag in your predicates).

## Custom Node Injection (PFS)

While BFS and DFS templates strictly operate on the lightweight [**gl::algorithm::search_node**](../../cpp-gl/structgl_1_1algorithm_1_1search__node.md), the Priority-First Search template often requires tracking dynamic state alongside the vertex ID.
Expand Down Expand Up @@ -93,18 +121,3 @@ gl::algorithm::pfs( // (5)!
return path_node{target_id, source_id, new_dist};
}
);
```

1. Define a custom stateful node tracking the distance accumulated so far.
2. Initialize a global distance map with "infinity", setting the start vertex distance to 0.
3. Define the priority comparator for a distance-based Min-Heap.
4. Setup the initial range containing the root node.
5. Run the Priority-First Search engine.
6. Define an empty vertex visit predicate and vertex visit callback.
7. Define the node enqueue predicate to only enqueue nodes that could yield paths shorter than those already discovered.
8. Define the callback which constructs a stateful node for the algorithm queue.
9. Update the global distance map to reflect the newly discovered shorter path.

> [!NOTE] Algorithm Desing
>
> The example above is very similar, though not the same, to how the Dijkstra's algorithm implementation is designed within the library.
37 changes: 24 additions & 13 deletions docs/hgl/algorithms/templates.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,9 @@ Because **BF-directed hypergraphs** distinguish between *tail* (source) and *hea

The true power of the generic templates lies in their callback and predicate hooks. Every iteration of the engine loop rigidly follows a defined sequence. By injecting custom callbacks (or omitting them via the imported [**empty_callback**](../../cpp-gl/group__HGL-Algorithm.md#typedef-empty_callback)), you dictate the algorithm's exact behavior.

### Execution Flowchart
### Standard Execution Flow (`bfs` and stateless `dfs`)

For a single popped `curr_node` in the `bfs` or `dfs` templates, the execution flow looks exactly like this:
For a single popped node in the standard traversal templates, the execution flow looks exactly like this:

1. **`visit_pred(curr_node)`**
Evaluated immediately after popping the node. If it returns `false`, the node is skipped entirely, and the loop moves to the next node in the queue or stack.
Expand All @@ -59,7 +59,7 @@ For a single popped `curr_node` in the `bfs` or `dfs` templates, the execution f
The engine queries the `traversal_policy` for the target hyperedges. For each `he_id`:

- **`traverse_he_pred(he_id, curr_node.vertex_id)`**
Evaluates whether the hyperedge should be traversed. Returns a `decision`:
Evaluates whether the hyperedge should be traversed. Returns a [**decision**](../../cpp-gl/structgl_1_1algorithm_1_1decision.md):
- `abort`: Kills the entire algorithm.
- `reject`: Ignores this hyperedge and moves to the next.
- `accept`: Proceeds to evaluate the hyperedge's vertices.
Expand All @@ -68,17 +68,31 @@ For a single popped `curr_node` in the `bfs` or `dfs` templates, the execution f
If the hyperedge was accepted, the engine queries the `traversal_policy` for the target vertices within that hyperedge. For each `target_id` (skipping the one we just came from):

- **`enqueue_pred(tgt_node)`**
Evaluates whether the newly constructed `tgt_node` (containing the `target_id`, `curr_node.vertex_id`, and `he_id`) should be pushed to the active container. Returns a `decision`:
Evaluates whether the newly constructed `tgt_node` (containing the `target_id`, `curr_node.vertex_id`, and `he_id`) should be pushed to the active container. Returns a [**decision**](../../cpp-gl/structgl_1_1algorithm_1_1decision.md):
- `abort`: Kills the entire algorithm.
- `reject`: Ignores this specific target vertex.
- `accept`: Pushes the `tgt_node` to the queue or stack.

6. **`post_visit(curr_node)`**
6. **`post_visit(curr_node)`** *(BFS only)*
Executed after all adjacent hyperedges and their target vertices have been evaluated and processed.

### True Post-Order Execution (Iterative `dfs`)

In a standard stack-based DFS, nodes are popped and discarded *before* their children are pushed. This makes executing a true post-order callback (firing only after a node's entire subtree has been exhaustively explored) impossible with a naive implementation.

The HGL `dfs` template solves this using a zero-cost abstraction:

- **Stateless Fast-Path:** If you pass an `empty_callback` for the `post_visit` hook, the engine compiles down to the standard execution flow described above, maximizing performance.
- **Stateful Stack-Frame:** If a valid `post_visit` callback is provided, the engine implicitly wraps the search nodes with a `dfs_extension` payload containing an `expanded` boolean flag.

When utilizing the stateful stack, the execution loop shifts to a two-phase lifecycle:

1. **Phase 1 (First Encounter):** The node is popped. Because `expanded == false`, the engine executes `visit_pred`, `pre_visit`, and `visit`. It then **marks the node as expanded and pushes it back onto the stack**, followed by executing the two-step hyperedge/vertex expansion to push all of its valid children on top.
2. **Phase 2 (Subtree Exhausted):** Because the parent was pushed beneath its children, it surfaces again only after its entire subtree has been popped and processed. The engine pops it, sees `expanded == true`, slices it back to its stateless base representation, and executes the `post_visit` callback.

## Customizing the Traversal

By wiring up these 6 hooks, you can build highly specific reachability algorithms. For instance, to ensure we do not get stuck in infinite loops, we need to track both visited vertices and visited hyperedges.
By wiring up these hooks, you can build highly specific reachability algorithms. For instance, to ensure we do not get stuck in infinite loops, we need to track both visited vertices and visited hyperedges.

### Example: Custom Forward BFS Engine

Expand All @@ -91,12 +105,9 @@ By wiring up these 6 hooks, you can build highly specific reachability algorithm
std::vector<bool> visited_v(hg.n_vertices(), false); // (1)!
std::vector<bool> visited_he(hg.n_hyperedges(), false);

using search_node = hgl::algorithm::search_node<decltype(hg)>;
std::vector<search_node> init_nodes = {search_node{start_id}}; // (2)!

bool success = hgl::algorithm::bfs<hgl::algorithm::forward>( // (3)!
bool success = hgl::algorithm::bfs<hgl::algorithm::forward>( // (2)!
hg,
init_nodes,
std::array{hgl::algorithm::root_node<decltype(hg)>(start_id)}, // (3)!
[&](const auto& node) { return not visited_v[node.vertex_id]; }, // (4)!
[&](const auto& node) { // (5)!
visited_v[node.vertex_id] = true;
Expand All @@ -116,8 +127,8 @@ bool success = hgl::algorithm::bfs<hgl::algorithm::forward>( // (3)!
```

1. Initialize state-tracking vectors for both vertices and hyperedges.
2. Set up the initial queue range with a root node.
3. Explicitly invoke the template with `traversal_direction::forward` (the default, but explicitly shown here for clarity).
2. Explicitly invoke the template with `traversal_direction::forward` (the default, but explicitly shown here for clarity).
3. Set up the initial queue range with a root node.
4. **`visit_pred`**: Reject nodes in the queue if they were already visited by an earlier, faster branch.
5. **`visit`**: Mark the vertex as visited.
6. **`traverse_he_pred`**: Check if the hyperedge was already traversed. If not, mark it traversed and `accept` it. Returning a `decision` type here is required by the generic engines.
Expand Down
8 changes: 4 additions & 4 deletions docs/hgl/quick_start.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,14 @@ int main() {
hg.add_hyperedge({v1, v2}, {v3})->weight = 5.0;

std::vector<hgl::default_id_type> roots = {0u};
auto search_tree = hgl::algorithm::backward_bfs(hg, roots); // (7)!
auto search_tree = hgl::algorithm::backward_bfs(hg, roots); // (6)!

if (hgl::algorithm::is_reachable(search_tree, 3u)) // (8)!
if (search_tree.is_reachable(3u)) // (7)!
std::cout << "Target is B-reachable from the Source.\n\n";
else
std::cout << "Target is NOT B-reachable from the Source.\n\n";

std::cout << hgl::io::verbose << hgl::io::with_properties; // (9)!
std::cout << hgl::io::verbose << hgl::io::with_properties; // (8)!
std::cout << "Hypergraph Topology:\n" << hg << '\n';

return 0;
Expand All @@ -57,7 +57,7 @@ int main() {
4. Retrieve stable vertex descriptors using `.vertex(id)` to assign property payloads safely.
5. BF-directed hyperedges connect a set of *tail* vertices to a set of *head* vertices. An edge can be added using vertex IDs or descriptors. <br/> **NOTE:** You can alternatively instantiate the hypergraph with a given number of hyperedges and simply *bind* vertices to them using the dedicated binding methods. <br/> **IMPORTANT:** Property Access Safety <br/> Assigning properties inline via the returned descriptor is safe here because the temporary descriptor is immediately discarded, meaning no dangling references are kept. The same bahaviour can be achieved using the `add_hyperedge_with` methods.
6. Execute a Breadth-First Backward Search (B-BFS) to compute B-reachability semantics from the source vertex.
7. Query the resulting search tree to validate if the Target vertex was successfully reached.
7. Query the resulting search tree to validate if the target vertex was successfully reached.
8. Standard GL stream manipulators inject persistent formatting state to output the hypergraph's structure in a verbose format, including the element properties.

**Output:**
Expand Down
54 changes: 38 additions & 16 deletions include/gl/algorithm/core.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ namespace gl::algorithm {
/// > and speeds up compilation times.
struct empty_callback {};

/// @ingroup GL-Algorithm
/// @brief A tag type used to explicitly indicate the absence of a state-tracking extension in a search node.
/// ### See Also
/// - @ref gl::algorithm::search_node "search_node" : For the definition of the algorithm search node type.
struct empty_extension {};

/// @ingroup GL-Algorithm GL-Types
/// @brief Represents a generic tri-state decision for control flow.
///
Expand Down Expand Up @@ -127,32 +133,48 @@ using predecessors_map = std::vector<id_t<G>>;

/// @ingroup GL-Algorithm
/// @brief Represents an active node in a search container (e.g., a BFS queue or DFS stack).
/// @tparam GraphType The type of the graph being searched.
template <traits::c_graph G>
/// @tparam G The type of the graph being searched.
/// @tparam Extension An optional payload type attached to the node for state tracking (must satisfy `std::semiregular`).
/// ### See Also
/// - @ref gl::algorithm::root_node "gl::algorithm::root_node" : For the full definition of the root search node builder function.
template <traits::c_graph G, std::semiregular Extension = empty_extension>
struct search_node {
/// @brief The underlying value type of the graph being searched.
using graph_type = val_t<G>;
/// @brief The integral type used to identify vertices in the graph.
using id_type = id_t<G>;
/// @brief The type of the custom state-tracking payload attached to this node.
using extension_type = Extension;

/// @brief Constructs a search node acting as a root (predecessor is itself).
/// @param vertex_id The ID of the vertex.
search_node(id_type vertex_id) : vertex_id(vertex_id), pred_id(vertex_id) {}

/// @brief Constructs a search node with an explicit predecessor.
/// @param vertex_id The ID of the vertex.
/// @param pred_id The ID of the vertex's predecessor.
search_node(id_type vertex_id, id_type pred_id) : vertex_id(vertex_id), pred_id(pred_id) {}
id_type vertex_id = invalid_id; ///< The ID of the vertex currently being searched.
id_type pred_id = invalid_id; ///< The ID of the predecessor from which this vertex was reached.
[[no_unique_address]] extension_type ext = {}; ///< Custom state-tracking payload.

/// @brief Checks if this node is the root of a search tree.
/// @return `true` if the node is valid and its predecessor is itself, `false` otherwise.
/// @return `true` if the node is valid and `vertex_id == pred_id`, `false` otherwise.
[[nodiscard]] gl_attr_force_inline bool is_root() const noexcept {
return this->vertex_id != invalid_id and this->vertex_id == this->pred_id;
}

/// @brief The ID of the vertex currently being searched.
id_type vertex_id;
/// @brief The ID of the predecessor from which this vertex was reached.
id_type pred_id;
};

/// @ingroup GL-Algorithm
/// @brief Free function builder that creates a search node acting as the root of a search tree.
///
/// This utility provides clean, unambiguous aggregate initialization semantics for root nodes
/// (where the vertex is strictly its own predecessor) at algorithmic call sites.
///
/// @tparam G The type of the graph being searched.
/// @tparam Extension The type of the custom state-tracking payload attached to the node.
/// @param root_id The ID of the root vertex.
/// @param ext An optional state-tracking extension payload.
/// @return A fully initialized @ref gl::algorithm::search_node "search_node" acting as a root.
template <traits::c_graph G, std::semiregular Extension = empty_extension>
[[nodiscard]] gl_attr_force_inline search_node<val_t<G>, Extension> root_node(
id_t<G> root_id, Extension ext = {}
) {
return search_node<val_t<G>, Extension>{root_id, root_id, std::move(ext)};
}

// --- constants ---

/// @ingroup GL-Algorithm
Expand Down
Loading
Loading