From f859173b9804f4eed677951a8760ea728f65d717 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 2 Jul 2026 20:26:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(graph):=20filter=20model=20=E2=80=94=20loc?= =?UTF-8?q?al-graph=20BFS,=20subgraph,=20GraphFilter=20(T-323)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- lib/src/graph/graph_filter.dart | 47 +++++++++++++++++++++ lib/src/graph/vault_graph.dart | Bin 2401 -> 3795 bytes test/src/graph/graph_filter_test.dart | 56 ++++++++++++++++++++++++++ test/src/graph/vault_graph_test.dart | 35 ++++++++++++++++ 4 files changed, 138 insertions(+) create mode 100644 lib/src/graph/graph_filter.dart create mode 100644 test/src/graph/graph_filter_test.dart diff --git a/lib/src/graph/graph_filter.dart b/lib/src/graph/graph_filter.dart new file mode 100644 index 00000000..faf88b4c --- /dev/null +++ b/lib/src/graph/graph_filter.dart @@ -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 includeTags; + + /// Drop notes tagged with any of these. + final Set excludeTags; + + bool get isEmpty => depth == null && includeTags.isEmpty && excludeTags.isEmpty; + + GraphFilter copyWith({int? depth, bool clearDepth = false, Set? includeTags, Set? 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> tagsByPath, String? activePath}) { + if (isEmpty) return full; + Set 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 ? {} : keep.intersection(full.nodesWithin(activePath, depth!)); + } + return full.subgraph(keep); + } +} diff --git a/lib/src/graph/vault_graph.dart b/lib/src/graph/vault_graph.dart index a38f70975e4cbafedc8955b8131b8ace721c908c..2d1da9acd85462465cda8a424a0a11529fa59eb5 100644 GIT binary patch delta 1366 zcmZWp!EO^V5EU0HdgKmz^iX!yu}1_;?Un5`MQOGC0H<=} zCy@9Q_<`~z%-Fk0TQ0j=&&-=QZ=Uz=`R^A$?>26KJbl(UB_^d9R|->SFEDi0h7lGz zl-gi;A+Fx5I+P=nw)U`>%3*G#c4`uogpxDmH-X*VU1Vb$N>tjMBgM5fKJP)BNi~<+ z%sOllAQ=;T`1`f_j!-IWRdymO%$%sptrk!?ORXSaY)}P`F6!_Fi&7D-L{fY6I>fkC zZzIe-6xb<5i)5^MW2{urp229cSwW!_75dFdaP)aV=TiC4aqKLefx9sOfKG-SOR59( z6j^tMJ`HkxjaLz&5avsknH&RxEVRU65OMMxPKCKMn2;<1%bWd(v^CX+1{d-S1N2kl zUS!r1J6PU++SqO~${4DGcQnG`;eY`0LP(jZBG^1(0c}>Wk8`-L&AN$wQ(Lb{O~T?y zTSAw@u@g+twCYu;Nk!Hn;{+NDd;9e2ucX2D_NuPX{@8}NFiP6l>0|>^TcfVRdfbCf z3kq|Clta>y0Xrtm8sb5g8FLnEqb1MWs@n=l$oK2g-gxGyoEg(nm`eB6sR$&E*6(Sv zR)i~3X3A>U%CLNVvq=xW5AO(jksQrlJe$+j+3L|ILke-njt;yW*8T~(z;=j8fF^l=WR48%18y-ItXoi#;#{!dogE}q*^!lC>efv zZ_^QW)tf2_pf%&FtO-Z_tNEW8^CBHh`V~FqRR?Ofrk3tc&Q|4qk79I>a;OKi+ufdu Vmz|c`ifAulL|XZ;KO5hk`~y+)tvmn# delta 12 TcmcaC`%q}ZL-x(VybX*1CSL^* diff --git a/test/src/graph/graph_filter_test.dart b/test/src/graph/graph_filter_test.dart new file mode 100644 index 00000000..7b11d86b --- /dev/null +++ b/test/src/graph/graph_filter_test.dart @@ -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'}); + }); +} diff --git a/test/src/graph/vault_graph_test.dart b/test/src/graph/vault_graph_test.dart index ec27a702..2f642049 100644 --- a/test/src/graph/vault_graph_test.dart +++ b/test/src/graph/vault_graph_test.dart @@ -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 + }); }