-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Aho-Corasick Freelinks Enhancement for Large Wikis and Non-Latin Titles #9084
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
06cbbe8
bbef43c
6c05f06
9138953
9316a53
ece4907
bd4ae9f
f3b673a
01c2ef7
5002e36
da803cf
a0cf994
6b702ca
002ab04
3402cc5
0b13f17
9bcb309
2093f26
1944783
f786038
7c3b62b
8976b53
89e9fff
5f0b98d
b022fcf
c0de611
43f4521
a27c867
1de0e70
21dde76
873e238
5cff761
e832a5b
5330d09
167f64c
7270600
ac72f0b
43685ab
d398f59
4c8afd1
291d511
adef812
85826f2
1e466e7
a20fe85
506927d
9c90b87
951edc3
a2b252d
1d31619
d09764f
dcd6a61
13a2fa9
6f1721f
98a0713
8f86e6b
3a6f7d2
72db370
ae73b1b
ff0507d
2c73f62
62995b6
b254d13
b03c6c9
6536ed6
41c493b
f2aa7eb
fcd6681
0141a9f
792a00f
bfc9f07
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,242 @@ | ||
| /*\ | ||
| title: $:/core/modules/utils/aho-corasick.js | ||
| type: application/javascript | ||
| module-type: utils | ||
|
|
||
| Optimized Aho-Corasick string matching algorithm implementation with enhanced performance | ||
| and error handling for TiddlyWiki freelinking functionality. | ||
|
|
||
| Useage: | ||
|
|
||
| Initialization: | ||
| Create an AhoCorasick instance: var ac = new AhoCorasick(); | ||
| After initialization, the trie and failure structures are automatically created to store patterns and failure links. | ||
|
|
||
| Adding Patterns: | ||
| Call addPattern(pattern, index) to add a pattern, e.g., ac.addPattern("[[Link]]", 0);. | ||
| pattern is the string to match, and index is an identifier for tracking results. | ||
| Multiple patterns can be added, stored in the trie structure. | ||
|
|
||
| Building Failure Links: | ||
| Call buildFailureLinks() to construct failure links for efficient multi-pattern matching. | ||
| Includes a maximum node limit (default 100,000 or 15 times the pattern count) to prevent excessive computation. | ||
|
|
||
| Performing Search: | ||
| Use search(text, useWordBoundary) to find pattern matches in the text. | ||
| text is the input string, and useWordBoundary (boolean) controls whether to enforce word boundary checks. | ||
| Returns an array of match results, each containing pattern (matched pattern), index (start position), length (pattern length), and titleIndex (pattern identifier). | ||
|
|
||
| Word Boundary Check: | ||
| If useWordBoundary is true, only matches surrounded by non-word characters (letters, digits, or underscores) are returned. | ||
|
|
||
| Cleanup and Statistics: | ||
| Use clear() to reset the trie and failure links, freeing memory. | ||
| Use getStats() to retrieve statistics, including node count (nodeCount), pattern count (patternCount), and failure link count (failureLinks). | ||
|
|
||
| Notes | ||
| Performance Considerations: The Aho-Corasick trie may consume significant memory with a large number of patterns. Limit the number of patterns (e.g., <10,000) for optimal performance. | ||
| Error Handling: The module includes maximum node and failure depth limits (maxFailureDepth) to prevent infinite loops or memory overflow. | ||
| Word Boundary: Enabling useWordBoundary ensures more precise matches, ideal for link detection scenarios. | ||
| Compatibility: Ensure compatibility with other TiddlyWiki modules (e.g., wikiparser.js) when processing WikiText. | ||
| Debugging: Use getStats() to inspect the trie structure's size and ensure it does not overload browser memory. | ||
|
|
||
| \*/ | ||
|
|
||
| "use strict"; | ||
|
|
||
| function AhoCorasick() { | ||
| this.trie = {}; | ||
| this.failure = {}; | ||
| this.maxFailureDepth = 100; | ||
| this.patternCount = 0; | ||
| } | ||
|
|
||
| AhoCorasick.prototype.addPattern = function(pattern, index) { | ||
| if(!pattern || typeof pattern !== "string" || pattern.length === 0) { | ||
| return; | ||
| } | ||
|
|
||
| var node = this.trie; | ||
|
|
||
| for(var i = 0; i < pattern.length; i++) { | ||
| var char = pattern[i]; | ||
| if(!node[char]) { | ||
| node[char] = {}; | ||
| } | ||
| node = node[char]; | ||
| } | ||
|
|
||
| if(!node.$) { | ||
| node.$ = []; | ||
| } | ||
| node.$.push({ | ||
| pattern: pattern, | ||
| index: index, | ||
| length: pattern.length | ||
| }); | ||
|
|
||
| this.patternCount++; | ||
| }; | ||
|
|
||
| AhoCorasick.prototype.buildFailureLinks = function() { | ||
| var queue = []; | ||
| var root = this.trie; | ||
| this.failure[root] = root; | ||
|
|
||
| for(var char in root) { | ||
| if(root[char] && char !== "$") { | ||
| this.failure[root[char]] = root; | ||
| queue.push(root[char]); | ||
| } | ||
| } | ||
|
|
||
| var processedNodes = 0; | ||
| var maxNodes = Math.max(100000, this.patternCount * 15); | ||
|
|
||
| while(queue.length > 0 && processedNodes < maxNodes) { | ||
| var node = queue.shift(); | ||
| processedNodes++; | ||
|
|
||
| for(var char in node) { | ||
| if(node[char] && char !== "$") { | ||
| var child = node[char]; | ||
| var fail = this.failure[node]; | ||
| var failureDepth = 0; | ||
|
|
||
| while(fail && !fail[char] && failureDepth < this.maxFailureDepth) { | ||
| fail = this.failure[fail]; | ||
| failureDepth++; | ||
| } | ||
|
|
||
| var failureLink = (fail && fail[char]) ? fail[char] : root; | ||
| this.failure[child] = failureLink; | ||
|
|
||
| var failureOutput = this.failure[child]; | ||
| if(failureOutput && failureOutput.$) { | ||
| if(!child.$) { | ||
| child.$ = []; | ||
| } | ||
| child.$.push.apply(child.$, failureOutput.$); | ||
| } | ||
|
|
||
| queue.push(child); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| if(processedNodes >= maxNodes) { | ||
| throw new Error("Aho-Corasick: buildFailureLinks exceeded maximum nodes (" + maxNodes + ")"); | ||
| } | ||
| }; | ||
|
|
||
| AhoCorasick.prototype.search = function(text, useWordBoundary) { | ||
| if(!text || typeof text !== "string" || text.length === 0) { | ||
| return []; | ||
| } | ||
|
|
||
| var matches = []; | ||
| var node = this.trie; | ||
| var textLength = text.length; | ||
| var maxMatches = Math.min(textLength * 2, 10000); | ||
|
|
||
| for(var i = 0; i < textLength; i++) { | ||
| var char = text[i]; | ||
| var transitionCount = 0; | ||
|
|
||
| while(node && !node[char] && node !== this.trie && transitionCount < this.maxFailureDepth) { | ||
| node = this.failure[node] || this.trie; | ||
| transitionCount++; | ||
| } | ||
|
|
||
| if(node && node[char]) { | ||
| node = node[char]; | ||
| } else { | ||
| node = this.trie; | ||
| if(this.trie[char]) { | ||
| node = this.trie[char]; | ||
| } | ||
| } | ||
|
|
||
| var currentNode = node; | ||
| var collectCount = 0; | ||
| while(currentNode && collectCount < 10) { | ||
| if(currentNode.$) { | ||
| var outputs = currentNode.$; | ||
| for(var j = 0; j < outputs.length && matches.length < maxMatches; j++) { | ||
| var output = outputs[j]; | ||
| var matchStart = i - output.length + 1; | ||
| var matchEnd = i + 1; | ||
|
|
||
| if(useWordBoundary && !this.isWordBoundaryMatch(text, matchStart, matchEnd)) { | ||
| continue; | ||
| } | ||
|
|
||
| matches.push({ | ||
| pattern: output.pattern, | ||
| index: matchStart, | ||
| length: output.length, | ||
| titleIndex: output.index | ||
| }); | ||
| } | ||
| } | ||
| currentNode = this.failure[currentNode]; | ||
| if(currentNode === this.trie) break; | ||
| collectCount++; | ||
| } | ||
| } | ||
|
|
||
| return matches; | ||
| }; | ||
|
|
||
| AhoCorasick.prototype.isWordBoundaryMatch = function(text, start, end) { | ||
| var beforeChar = start > 0 ? text[start - 1] : ""; | ||
| var afterChar = end < text.length ? text[end] : ""; | ||
|
|
||
| var isWordChar = function(char) { | ||
| return /[a-zA-Z0-9_\u00C0-\u00FF]/.test(char); | ||
| }; | ||
|
|
||
| var beforeIsWord = beforeChar && isWordChar(beforeChar); | ||
| var afterIsWord = afterChar && isWordChar(afterChar); | ||
|
|
||
| return !beforeIsWord && !afterIsWord; | ||
| }; | ||
|
|
||
| AhoCorasick.prototype.clear = function() { | ||
| this.trie = {}; | ||
| this.failure = {}; | ||
| this.patternCount = 0; | ||
| }; | ||
|
|
||
| AhoCorasick.prototype.getStats = function() { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @s793016 @linonetwo are we sure that this method has the correct logic? In
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Simply put: it counts one variable but returns another, effectively rendering the counting useless. This The Additionally, thanks to Claude Sonnet 4.6, we've identified other issues that will be addressed collectively.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @s793016 Now you see AI are lazy, they tend to hide the error using eslint-disable, or modify the test case so its work could easily done. Make sure to use Claude Opus 4.6 to final review the code.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think it's not that AI is lazy, but that the AI at the time wasn't strong enough. The first version before June 2025 was clumsily put together by Grok, then to extract the algorithm independently, Grok couldn't handle it and passed the baton to Claude, and later I showed the code to ChatGPT, who also didn't spot the issues. Until a couple of days ago when Claude 4.6 came online, it immediately identified several logic problems—the progress of AI in this past half year or more has really been tremendous. Back in mid-2025, the models (including earlier Groks) were still hitting limits on complex refactors like extracting algorithms without introducing subtle logic bugs. Handing it off to Claude made sense at the time, and even ChatGPT missing those issues highlights how context-dependent error detection can be. Fast-forward to now (early 2026), and yeah, the jumps in reasoning depth are huge. Claude 4.6 spotting those logic flaws right away? That's a testament to better multi-step thinking and pattern recognition in newer architectures. We've seen similar strides across the board.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| var nodeCount = 0; | ||
| var patternCount = 0; | ||
| var failureCount = 0; | ||
|
|
||
| function countNodes(node) { | ||
| if(!node) return; | ||
| nodeCount++; | ||
| if(node.$) { | ||
| patternCount += node.$.length; | ||
| } | ||
| for(var key in node) { | ||
| if(node[key] && typeof node[key] === "object" && key !== "$") { | ||
| countNodes(node[key]); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| countNodes(this.trie); | ||
|
|
||
| for(var key in this.failure) { | ||
| failureCount++; | ||
| } | ||
|
|
||
| return { | ||
| nodeCount: nodeCount, | ||
| patternCount: this.patternCount, | ||
| failureLinks: failureCount | ||
| }; | ||
| }; | ||
|
|
||
| exports.AhoCorasick = AhoCorasick; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| title: $:/config/Freelinks/WordBoundary | ||
| text: yes |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There is the question if we want to "throw" an RSOD error here, or if we can fail more silently.
IMO the wiki is not broken -- the index has a problem. -- I'm not sure
We will need a translatable string here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a valid concern. Instead of throwing a hard error that could crash the application, you might want to log a warning and gracefully degrade functionality. The error message should indeed be translatable for internationalization.
For the discussion points: These need more consideration and discussion before implementing changes, as they involve functionality and internationalization concerns.