feat(graph): filter model — local-graph BFS, subgraph, GraphFilter (T-323)
Adds the pure filtering primitives the graph pane composes: VaultGraph nodesWithin (depth-bounded BFS over undirected edges = the local graph around a note) and subgraph (retain a node set + the edges between them), plus GraphFilter, which combines depth-from-active with tag include/exclude over a caller-supplied tag map. Flutter-free; runs under dart test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
/// Client-side filtering of a loaded [VaultGraph] (T-323): narrow to the notes
|
||||
/// near the active one (depth-from-active), and/or by tag include/exclude.
|
||||
///
|
||||
/// Pure — the controller feeds in the already-queried tag map, so this runs
|
||||
/// under `dart test`. The file glob is NOT here: a different glob is a
|
||||
/// different file set, so it re-queries pql; these three refine what's already
|
||||
/// loaded.
|
||||
library;
|
||||
|
||||
import 'package:clide/src/graph/vault_graph.dart';
|
||||
|
||||
class GraphFilter {
|
||||
const GraphFilter({this.depth, this.includeTags = const {}, this.excludeTags = const {}});
|
||||
|
||||
/// Hops from the active note to keep; null = the whole graph (no depth limit).
|
||||
final int? depth;
|
||||
|
||||
/// Keep only notes tagged with at least one of these (empty = no filter).
|
||||
final Set<String> includeTags;
|
||||
|
||||
/// Drop notes tagged with any of these.
|
||||
final Set<String> excludeTags;
|
||||
|
||||
bool get isEmpty => depth == null && includeTags.isEmpty && excludeTags.isEmpty;
|
||||
|
||||
GraphFilter copyWith({int? depth, bool clearDepth = false, Set<String>? includeTags, Set<String>? excludeTags}) =>
|
||||
GraphFilter(depth: clearDepth ? null : (depth ?? this.depth), includeTags: includeTags ?? this.includeTags, excludeTags: excludeTags ?? this.excludeTags);
|
||||
|
||||
/// Apply this filter to [full], using [tagsByPath] for the tag predicates and
|
||||
/// [activePath] as the depth root. A depth filter with no active path (or one
|
||||
/// that isn't a node) yields an empty graph — there's no local graph to show.
|
||||
VaultGraph apply(VaultGraph full, {required Map<String, Set<String>> tagsByPath, String? activePath}) {
|
||||
if (isEmpty) return full;
|
||||
Set<String> tagsOf(String id) => tagsByPath[id] ?? const {};
|
||||
var keep = {for (final n in full.nodes) n.id};
|
||||
if (includeTags.isNotEmpty) {
|
||||
keep = keep.where((id) => tagsOf(id).any(includeTags.contains)).toSet();
|
||||
}
|
||||
if (excludeTags.isNotEmpty) {
|
||||
keep = keep.where((id) => !tagsOf(id).any(excludeTags.contains)).toSet();
|
||||
}
|
||||
if (depth != null) {
|
||||
keep = activePath == null ? <String>{} : keep.intersection(full.nodesWithin(activePath, depth!));
|
||||
}
|
||||
return full.subgraph(keep);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,56 @@
|
||||
import 'package:clide/src/graph/graph_filter.dart';
|
||||
import 'package:clide/src/graph/vault_graph.dart';
|
||||
import 'package:test/test.dart';
|
||||
|
||||
void main() {
|
||||
final g = VaultGraph.fromOutlinks({
|
||||
'a.md': const ['b.md'],
|
||||
'b.md': const ['c.md'],
|
||||
'c.md': const [],
|
||||
'x.md': const [],
|
||||
}); // a—b—c chain, x isolated
|
||||
final tags = {
|
||||
'a.md': {'project'},
|
||||
'b.md': {'note', 'project'},
|
||||
'c.md': {'note'},
|
||||
};
|
||||
|
||||
test('an empty filter returns the full graph unchanged', () {
|
||||
const f = GraphFilter();
|
||||
expect(identical(f.apply(g, tagsByPath: tags), g), isTrue); // no-op short-circuit
|
||||
});
|
||||
|
||||
test('includeTags keeps only notes carrying a matching tag', () {
|
||||
const f = GraphFilter(includeTags: {'project'});
|
||||
expect(f.apply(g, tagsByPath: tags).nodes.map((n) => n.id), unorderedEquals(['a.md', 'b.md']));
|
||||
});
|
||||
|
||||
test('excludeTags drops notes carrying a matching tag', () {
|
||||
const f = GraphFilter(excludeTags: {'note'});
|
||||
// b and c carry 'note' → dropped; a (project) and x (untagged) survive.
|
||||
expect(f.apply(g, tagsByPath: tags).nodes.map((n) => n.id), unorderedEquals(['a.md', 'x.md']));
|
||||
});
|
||||
|
||||
test('depth keeps the local graph around the active note', () {
|
||||
const f = GraphFilter(depth: 1);
|
||||
expect(f.apply(g, tagsByPath: tags, activePath: 'a.md').nodes.map((n) => n.id), unorderedEquals(['a.md', 'b.md']));
|
||||
});
|
||||
|
||||
test('a depth filter with no active note yields nothing', () {
|
||||
const f = GraphFilter(depth: 2);
|
||||
expect(f.apply(g, tagsByPath: tags, activePath: null).isEmpty, isTrue);
|
||||
});
|
||||
|
||||
test('tag and depth compose by intersection', () {
|
||||
// include project → {a,b}; depth 2 from c → {c,b,a}; intersection → {a,b}.
|
||||
const f = GraphFilter(includeTags: {'project'}, depth: 2);
|
||||
expect(f.apply(g, tagsByPath: tags, activePath: 'c.md').nodes.map((n) => n.id), unorderedEquals(['a.md', 'b.md']));
|
||||
});
|
||||
|
||||
test('copyWith sets and clears the depth, keeping other fields', () {
|
||||
const f = GraphFilter(depth: 2, includeTags: {'project'});
|
||||
expect(f.copyWith(depth: 3).depth, 3);
|
||||
expect(f.copyWith(clearDepth: true).depth, isNull);
|
||||
expect(f.copyWith(clearDepth: true).includeTags, {'project'});
|
||||
});
|
||||
}
|
||||
@@ -48,4 +48,39 @@ void main() {
|
||||
expect(g.neighborhood('b.md'), {'b.md', 'a.md', 'c.md'});
|
||||
expect(g.neighborhood('x.md'), {'x.md'}); // isolated node
|
||||
});
|
||||
|
||||
group('nodesWithin', () {
|
||||
final g = VaultGraph.fromOutlinks({
|
||||
'a.md': const ['b.md'],
|
||||
'b.md': const ['c.md'],
|
||||
'c.md': const [],
|
||||
'x.md': const [],
|
||||
}); // a—b—c chain, x isolated
|
||||
|
||||
test('depth 0 is the root alone', () {
|
||||
expect(g.nodesWithin('a.md', 0), {'a.md'});
|
||||
});
|
||||
|
||||
test('depth grows the frontier over undirected edges, both directions', () {
|
||||
expect(g.nodesWithin('a.md', 1), {'a.md', 'b.md'});
|
||||
expect(g.nodesWithin('a.md', 2), {'a.md', 'b.md', 'c.md'});
|
||||
expect(g.nodesWithin('c.md', 2), {'c.md', 'b.md', 'a.md'}); // walks backwards too
|
||||
});
|
||||
|
||||
test('an isolated node is just itself; an unknown root is empty', () {
|
||||
expect(g.nodesWithin('x.md', 3), {'x.md'});
|
||||
expect(g.nodesWithin('ghost.md', 3), isEmpty);
|
||||
});
|
||||
});
|
||||
|
||||
test('subgraph keeps only the kept nodes and edges between them', () {
|
||||
final g = VaultGraph.fromOutlinks({
|
||||
'a.md': const ['b.md', 'c.md'],
|
||||
'b.md': const ['c.md'],
|
||||
'c.md': const [],
|
||||
});
|
||||
final sub = g.subgraph({'a.md', 'b.md'});
|
||||
expect(sub.nodes.map((n) => n.id), unorderedEquals(['a.md', 'b.md']));
|
||||
expect(sub.edgePairs, [('a.md', 'b.md')]); // a—c and b—c drop with c gone
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user