vruksha 0.0.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,164 @@
1
+ _docs/
2
+ _proc/
3
+
4
+ *.bak
5
+ .gitattributes
6
+ .last_checked
7
+ .gitconfig
8
+ *.bak
9
+ *.log
10
+ *~
11
+ ~*
12
+ _tmp*
13
+ tmp*
14
+ tags
15
+ *.pkg
16
+
17
+ # Byte-compiled / optimized / DLL files
18
+ __pycache__/
19
+ *.py[cod]
20
+ *$py.class
21
+
22
+ # C extensions
23
+ *.so
24
+
25
+ # Distribution / packaging
26
+ .Python
27
+ env/
28
+ build/
29
+ conda/
30
+ develop-eggs/
31
+ dist/
32
+ downloads/
33
+ eggs/
34
+ .eggs/
35
+ lib/
36
+ lib64/
37
+ parts/
38
+ sdist/
39
+ var/
40
+ wheels/
41
+ *.egg-info/
42
+ .installed.cfg
43
+ *.egg
44
+
45
+ # PyInstaller
46
+ # Usually these files are written by a python script from a template
47
+ # before PyInstaller builds the exe, so as to inject date/other infos into it.
48
+ *.manifest
49
+ *.spec
50
+
51
+ # Installer logs
52
+ pip-log.txt
53
+ pip-delete-this-directory.txt
54
+
55
+ # Unit test / coverage reports
56
+ htmlcov/
57
+ .tox/
58
+ .coverage
59
+ .coverage.*
60
+ .cache
61
+ nosetests.xml
62
+ coverage.xml
63
+ *.cover
64
+ .hypothesis/
65
+
66
+ # Translations
67
+ *.mo
68
+ *.pot
69
+
70
+ # Django stuff:
71
+ *.log
72
+ local_settings.py
73
+
74
+ # Flask stuff:
75
+ instance/
76
+ .webassets-cache
77
+
78
+ # Scrapy stuff:
79
+ .scrapy
80
+
81
+ # Sphinx documentation
82
+ docs/_build/
83
+
84
+ # PyBuilder
85
+ target/
86
+
87
+ # Jupyter Notebook
88
+ .ipynb_checkpoints
89
+
90
+ # pyenv
91
+ .python-version
92
+
93
+ # celery beat schedule file
94
+ celerybeat-schedule
95
+
96
+ # SageMath parsed files
97
+ *.sage.py
98
+
99
+ # dotenv
100
+ .env
101
+
102
+ # virtualenv
103
+ .venv
104
+ venv/
105
+ ENV/
106
+
107
+ # Spyder project settings
108
+ .spyderproject
109
+ .spyproject
110
+
111
+ # Rope project settings
112
+ .ropeproject
113
+
114
+ # mkdocs documentation
115
+ /site
116
+
117
+ # mypy
118
+ .mypy_cache/
119
+
120
+ .vscode
121
+ *.swp
122
+
123
+ # osx generated files
124
+ .DS_Store
125
+ .DS_Store?
126
+ .Trashes
127
+ ehthumbs.db
128
+ Thumbs.db
129
+ .idea
130
+
131
+ # pytest
132
+ .pytest_cache
133
+
134
+ # tools/trust-doc-nbs
135
+ docs_src/.last_checked
136
+
137
+ # symlinks to fastai
138
+ docs_src/fastai
139
+ tools/fastai
140
+
141
+ # link checker
142
+ checklink/cookies.txt
143
+
144
+ # .gitconfig is now autogenerated
145
+ .gitconfig
146
+
147
+ # Quarto installer
148
+ .deb
149
+ .pkg
150
+
151
+ # Quarto
152
+ .quarto
153
+
154
+ # local vault + extracted assets
155
+ *.db
156
+ *.usearch
157
+ # ...except the recorded model replies the notebook tests replay in CI. The sqlite side files are
158
+ # transient, so they stay ignored.
159
+ !nbs/chatcache/cache.db
160
+ nbs/chatcache/*.db-wal
161
+ nbs/chatcache/*.db-shm
162
+ assets/
163
+ .venv/
164
+ .kosha/
vruksha-0.0.2/LICENSE ADDED
@@ -0,0 +1,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright 2022, fastai
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
vruksha-0.0.2/PKG-INFO ADDED
@@ -0,0 +1,74 @@
1
+ Metadata-Version: 2.5
2
+ Name: vruksha
3
+ Version: 0.0.2
4
+ Summary: an entity graph over a litesearch store: extraction, resolution, and a PageRank search leg
5
+ Project-URL: Repository, https://github.com/vedicreader/vruksha
6
+ Project-URL: Documentation, https://vedicreader.github.io/vruksha/
7
+ Author-email: Karthik <karthik.rajgopal@hotmail.com>
8
+ License: Apache-2.0
9
+ License-File: LICENSE
10
+ Keywords: entity resolution,knowledge graph,nbdev,pagerank,search,sqlite
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: Apache Software License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Text Processing :: Indexing
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: fastcore>=2.2.15
19
+ Requires-Dist: litesearch>=0.1.33
20
+ Requires-Dist: yake>=0.7.3
21
+ Description-Content-Type: text/markdown
22
+
23
+ # vruksha
24
+
25
+
26
+ <!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->
27
+
28
+ Four problems, and none of them needs a model.
29
+
30
+ | problem | how it is solved |
31
+ |----|----|
32
+ | what the entities are | AST symbols for code, yake keyphrases for prose |
33
+ | which mentions are the same thing | embeddings propose, a lexical guard decides |
34
+ | what connects to what | PMI over co-occurrence windows |
35
+ | what to do with the graph | personalised PageRank, fused as a third search leg |
36
+
37
+ ``` python
38
+ from litesearch import database
39
+ from vruksha import build_graph, resolve_entities
40
+
41
+ db = database('corpus.db')
42
+ build_graph(db, rows, emb_fn=enc) # entities and co-occurrence edges
43
+ resolve_entities(db) # `hnsw` and `HNSW index` become one node
44
+ db.graph_search('how does resolution work', qemb, graph_w=0.5)
45
+ ```
46
+
47
+ ## The lexical guard
48
+
49
+ Embedding similarity alone merges `python 3.11` into `python 3.12`. [`_lex_ok`](https://vedicreader.github.io/vruksha/entities.html#_lex_ok) requires token
50
+ overlap, matching digits and a matching acronym before a merge goes through.
51
+
52
+ ``` python
53
+ from vruksha.entities import _lex_ok
54
+
55
+ _lex_ok('usearch', 'usearch index'), _lex_ok('python 3.11', 'python 3.12')
56
+ ```
57
+
58
+ ## When to turn the search leg on
59
+
60
+ Measured against plain hybrid search:
61
+
62
+ - **Regulation and legal text: a loss.** p_mrr 0.8170 for plain hybrid against 0.7395, 0.6859 and
63
+ 0.6463 at `graph_w` 0.25, 0.5 and 1.0, at two to four times the latency.
64
+ - **Papers and prose: a win.** Better in seven of nine paired-bootstrap comparisons, +0.0387
65
+ target MRR on arXiv at `graph_w=1.0`.
66
+
67
+ So `graph_search` is opt-in by name and off by default. Turn it on for a corpus whose entities
68
+ carry meaning, and raise `graph_w` towards 1.0 when you do.
69
+
70
+ ## Install
71
+
72
+ ``` sh
73
+ pip install vruksha
74
+ ```
@@ -0,0 +1,52 @@
1
+ # vruksha
2
+
3
+
4
+ <!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->
5
+
6
+ Four problems, and none of them needs a model.
7
+
8
+ | problem | how it is solved |
9
+ |----|----|
10
+ | what the entities are | AST symbols for code, yake keyphrases for prose |
11
+ | which mentions are the same thing | embeddings propose, a lexical guard decides |
12
+ | what connects to what | PMI over co-occurrence windows |
13
+ | what to do with the graph | personalised PageRank, fused as a third search leg |
14
+
15
+ ``` python
16
+ from litesearch import database
17
+ from vruksha import build_graph, resolve_entities
18
+
19
+ db = database('corpus.db')
20
+ build_graph(db, rows, emb_fn=enc) # entities and co-occurrence edges
21
+ resolve_entities(db) # `hnsw` and `HNSW index` become one node
22
+ db.graph_search('how does resolution work', qemb, graph_w=0.5)
23
+ ```
24
+
25
+ ## The lexical guard
26
+
27
+ Embedding similarity alone merges `python 3.11` into `python 3.12`. [`_lex_ok`](https://vedicreader.github.io/vruksha/entities.html#_lex_ok) requires token
28
+ overlap, matching digits and a matching acronym before a merge goes through.
29
+
30
+ ``` python
31
+ from vruksha.entities import _lex_ok
32
+
33
+ _lex_ok('usearch', 'usearch index'), _lex_ok('python 3.11', 'python 3.12')
34
+ ```
35
+
36
+ ## When to turn the search leg on
37
+
38
+ Measured against plain hybrid search:
39
+
40
+ - **Regulation and legal text: a loss.** p_mrr 0.8170 for plain hybrid against 0.7395, 0.6859 and
41
+ 0.6463 at `graph_w` 0.25, 0.5 and 1.0, at two to four times the latency.
42
+ - **Papers and prose: a win.** Better in seven of nine paired-bootstrap comparisons, +0.0387
43
+ target MRR on arXiv at `graph_w=1.0`.
44
+
45
+ So `graph_search` is opt-in by name and off by default. Turn it on for a corpus whose entities
46
+ carry meaning, and raise `graph_w` towards 1.0 when you do.
47
+
48
+ ## Install
49
+
50
+ ``` sh
51
+ pip install vruksha
52
+ ```
@@ -0,0 +1,47 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "vruksha"
7
+ dynamic = ["version"]
8
+ description = "an entity graph over a litesearch store: extraction, resolution, and a PageRank search leg"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = {text = "Apache-2.0"}
12
+ authors = [{name = "Karthik", email = "karthik.rajgopal@hotmail.com"}]
13
+ keywords = ['nbdev', 'knowledge graph', 'entity resolution', 'pagerank', 'search', 'sqlite']
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: Apache Software License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Topic :: Text Processing :: Indexing",
21
+ ]
22
+ dependencies = [
23
+ "fastcore>=2.2.15",
24
+ "litesearch>=0.1.33",
25
+ "yake>=0.7.3",
26
+ ]
27
+
28
+ [project.urls]
29
+ Repository = "https://github.com/vedicreader/vruksha"
30
+ Documentation = "https://vedicreader.github.io/vruksha/"
31
+
32
+ [project.entry-points.nbdev]
33
+ vruksha = "vruksha._modidx:d"
34
+
35
+ [tool.nbdev]
36
+
37
+ [tool.hatch.build.targets.wheel]
38
+ packages = ["vruksha"]
39
+
40
+ [tool.hatch.build.targets.sdist]
41
+ include = ["/vruksha", "/README.md", "/pyproject.toml"]
42
+
43
+ [tool.hatch.version]
44
+ path = "vruksha/__init__.py"
45
+
46
+ [dependency-groups]
47
+ dev = ["ipykernel>=7.3.0", "nbdev>=3.3.4", "notebook>=7.6.1"]
@@ -0,0 +1,4 @@
1
+ __version__ = "0.0.2"
2
+ from .entities import *
3
+ from .build import *
4
+ from .search import *
@@ -0,0 +1,47 @@
1
+ # Autogenerated by nbdev
2
+
3
+ d = { 'settings': { 'branch': 'main',
4
+ 'doc_baseurl': '/vruksha',
5
+ 'doc_host': 'https://vedicreader.github.io',
6
+ 'git_url': 'https://github.com/vedicreader/vruksha',
7
+ 'lib_path': 'vruksha'},
8
+ 'syms': { 'vruksha.build': { 'vruksha.build._WindowStore': ('build.html#_windowstore', 'vruksha/build.py'),
9
+ 'vruksha.build._WindowStore.__init__': ('build.html#_windowstore.__init__', 'vruksha/build.py'),
10
+ 'vruksha.build._WindowStore.__iter__': ('build.html#_windowstore.__iter__', 'vruksha/build.py'),
11
+ 'vruksha.build._WindowStore.__len__': ('build.html#_windowstore.__len__', 'vruksha/build.py'),
12
+ 'vruksha.build._WindowStore.drop': ('build.html#_windowstore.drop', 'vruksha/build.py'),
13
+ 'vruksha.build._WindowStore.entity_counts': ('build.html#_windowstore.entity_counts', 'vruksha/build.py'),
14
+ 'vruksha.build._WindowStore.extend': ('build.html#_windowstore.extend', 'vruksha/build.py'),
15
+ 'vruksha.build._WindowStore.pair_counts': ('build.html#_windowstore.pair_counts', 'vruksha/build.py'),
16
+ 'vruksha.build._ann_pairs': ('build.html#_ann_pairs', 'vruksha/build.py'),
17
+ 'vruksha.build._collapse_edges': ('build.html#_collapse_edges', 'vruksha/build.py'),
18
+ 'vruksha.build._is_code': ('build.html#_is_code', 'vruksha/build.py'),
19
+ 'vruksha.build._lexical_pairs': ('build.html#_lexical_pairs', 'vruksha/build.py'),
20
+ 'vruksha.build._n_workers': ('build.html#_n_workers', 'vruksha/build.py'),
21
+ 'vruksha.build._pmi_edges': ('build.html#_pmi_edges', 'vruksha/build.py'),
22
+ 'vruksha.build._pool': ('build.html#_pool', 'vruksha/build.py'),
23
+ 'vruksha.build._prose_job': ('build.html#_prose_job', 'vruksha/build.py'),
24
+ 'vruksha.build._uf_find': ('build.html#_uf_find', 'vruksha/build.py'),
25
+ 'vruksha.build._uf_union': ('build.html#_uf_union', 'vruksha/build.py'),
26
+ 'vruksha.build.build_graph': ('build.html#build_graph', 'vruksha/build.py'),
27
+ 'vruksha.build.cooccur_edges': ('build.html#cooccur_edges', 'vruksha/build.py'),
28
+ 'vruksha.build.resolve_entities': ('build.html#resolve_entities', 'vruksha/build.py')},
29
+ 'vruksha.entities': { 'vruksha.entities._acr': ('entities.html#_acr', 'vruksha/entities.py'),
30
+ 'vruksha.entities._def_name': ('entities.html#_def_name', 'vruksha/entities.py'),
31
+ 'vruksha.entities._jac': ('entities.html#_jac', 'vruksha/entities.py'),
32
+ 'vruksha.entities._lex_ok': ('entities.html#_lex_ok', 'vruksha/entities.py'),
33
+ 'vruksha.entities._norm': ('entities.html#_norm', 'vruksha/entities.py'),
34
+ 'vruksha.entities._nums': ('entities.html#_nums', 'vruksha/entities.py'),
35
+ 'vruksha.entities._sentences': ('entities.html#_sentences', 'vruksha/entities.py'),
36
+ 'vruksha.entities._toks': ('entities.html#_toks', 'vruksha/entities.py'),
37
+ 'vruksha.entities._yake_terms': ('entities.html#_yake_terms', 'vruksha/entities.py'),
38
+ 'vruksha.entities.code_entities': ('entities.html#code_entities', 'vruksha/entities.py'),
39
+ 'vruksha.entities.prose_windows': ('entities.html#prose_windows', 'vruksha/entities.py'),
40
+ 'vruksha.entities.text_entities': ('entities.html#text_entities', 'vruksha/entities.py')},
41
+ 'vruksha.search': { 'vruksha.search.Database.graph_search': ('search.html#database.graph_search', 'vruksha/search.py'),
42
+ 'vruksha.search._adjacency': ('search.html#_adjacency', 'vruksha/search.py'),
43
+ 'vruksha.search._canon_mentions': ('search.html#_canon_mentions', 'vruksha/search.py'),
44
+ 'vruksha.search._csr': ('search.html#_csr', 'vruksha/search.py'),
45
+ 'vruksha.search._leg': ('search.html#_leg', 'vruksha/search.py'),
46
+ 'vruksha.search._ppr': ('search.html#_ppr', 'vruksha/search.py'),
47
+ 'vruksha.search.graph_stats': ('search.html#graph_stats', 'vruksha/search.py')}}}
@@ -0,0 +1,438 @@
1
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/01_build.ipynb.
2
+
3
+ # %% auto #0
4
+ __all__ = ['MIN_PARALLEL_CHUNKS', 'build_graph', 'resolve_entities', 'cooccur_edges']
5
+
6
+ # %% ../nbs/01_build.ipynb #ab82e9d12af8898f
7
+ import ast, math, re, sys
8
+ from functools import lru_cache
9
+ import numpy as np
10
+ from fastcore.all import AttrDict, L, Path, chunked, defaults, first, ifnone, merge, patch, store_attr
11
+ from fastcore.parallel import ProcessPoolExecutor
12
+ from fastlite import Database
13
+ from apswutils.db import Table
14
+ from multiprocessing import get_context
15
+ from litesearch.core import (sql_in, rowid_sel, content_id, NP_DTYPE, process_content, write_txn, db_lock,
16
+ upsert_all, rrf_all)
17
+ from litesearch.topics import get_graph
18
+ from litesearch.utils import hash_embed
19
+ from .entities import (_acr, _jac, _lex_ok, _norm, _nums, _sentences, _toks, _yake_terms,
20
+ code_entities, prose_windows, text_entities)
21
+
22
+ # %% ../nbs/01_build.ipynb #8e1af70983a03a3f
23
+ _CODE_TYPES = {'FunctionDef','AsyncFunctionDef','ClassDef'}
24
+
25
+ def _is_code(chunk):
26
+ md = chunk.get('metadata') or {}
27
+ if not isinstance(md, dict): return False
28
+ return md.get('lang') == '.py' or md.get('type') in _CODE_TYPES
29
+
30
+ def _pmi_edges(wins, # list of entity-id sets (one per co-occurrence window)
31
+ min_n=2, # min co-occurrence count
32
+ min_npmi=0.15, # min normalized PMI
33
+ max_df=0.4, # drop entities present in more than this fraction of windows
34
+ max_degree=48, # keep only the strongest edges per node
35
+ rel='cooc'):
36
+ 'Normalized-PMI co-occurrence edges over windows. max_df is what kills hub terms like "section".'
37
+ n = len(wins)
38
+ if n < 2: return []
39
+ if hasattr(wins, 'pair_counts'): # a _WindowStore counts on disk, same numbers
40
+ cnt = wins.entity_counts()
41
+ hub = {e for e, c in cnt.items() if c / n > max_df}
42
+ pairs = ((a, b, c) for a, b, c in wins.pair_counts(hub, min_n))
43
+ else:
44
+ cnt = {}
45
+ for w in wins:
46
+ for e in w: cnt[e] = cnt.get(e, 0) + 1
47
+ hub = {e for e, c in cnt.items() if c / n > max_df}
48
+ pair = {}
49
+ for w in wins:
50
+ es = sorted(e for e in w if e not in hub)
51
+ for i, a in enumerate(es):
52
+ for b in es[i+1:]: pair[(a, b)] = pair.get((a, b), 0) + 1
53
+ pairs = ((a, b, c) for (a, b), c in pair.items())
54
+ out = []
55
+ for a, b, c in pairs:
56
+ if c < min_n: continue
57
+ pa, pb, pab = cnt[a]/n, cnt[b]/n, c/n
58
+ npmi = math.log(pab/(pa*pb)) / (-math.log(pab)) if 0 < pab < 1 else 0.0
59
+ if npmi < min_npmi: continue
60
+ out.append(dict(src=a, dst=b, rel=rel, weight=round(npmi, 5), n=c))
61
+ if max_degree:
62
+ # (src, dst) breaks weight ties explicitly. Without it the survivors of the degree cap depend
63
+ # on the order pairs were counted in, which differs between the in-memory dict and SQLite's
64
+ # group-by — 30 of 2,707 edges on a 500-chunk corpus, all of them ties, none of them wrong.
65
+ deg, kept = {}, []
66
+ for e in sorted(out, key=lambda r: (-r['weight'], r['src'], r['dst'])):
67
+ if deg.get(e['src'], 0) < max_degree and deg.get(e['dst'], 0) < max_degree:
68
+ deg[e['src']] = deg.get(e['src'], 0)+1; deg[e['dst']] = deg.get(e['dst'], 0)+1
69
+ kept.append(e)
70
+ out = kept
71
+ return out
72
+
73
+ # Below this many prose chunks in one drain, a process pool costs more to start than it saves.
74
+ MIN_PARALLEL_CHUNKS = 200
75
+
76
+ def _prose_job(txt):
77
+ '''Windows for one prose chunk, as plain tuples. Module level so a process pool can pickle it.
78
+
79
+ Only text crosses the boundary and only tuples come back — no database handle, nothing that a
80
+ fork would have to keep consistent.'''
81
+ return [[(s, k) for s, k in w] for w in prose_windows(txt)]
82
+
83
+ def _pool(nw):
84
+ '''A `ProcessPoolExecutor` started the way `fastcore.parallel` starts one.
85
+
86
+ fork, not spawn, on darwin. A spawned worker unpickles the job by module and name, and under
87
+ nbdev `_prose_job` is defined in the notebook's `__main__`, which the worker has no copy of —
88
+ so every worker died on unpickle. Only text crosses the boundary and only tuples come back,
89
+ so there is nothing here a fork has to keep consistent.'''
90
+ kw = dict(mp_context=get_context('fork')) if sys.platform == 'darwin' else {}
91
+ return ProcessPoolExecutor(nw, **kw)
92
+
93
+ def _n_workers(n, n_workers):
94
+ 'Resolve `n_workers`: None picks by size, 0 stays serial, anything else is taken literally.'
95
+ if n_workers is not None: return n_workers
96
+ return 0 if n < MIN_PARALLEL_CHUNKS else defaults.cpus
97
+
98
+ # %% ../nbs/01_build.ipynb #bd12970f219d8213
99
+ class _WindowStore:
100
+ '''Co-occurrence windows kept in a SQLite table instead of a python list.
101
+ The table is TEMP: scratch for one build does not belong in the shared schema, where creating
102
+ and dropping it bumps the schema cookie and forces every other connection to re-prepare.'''
103
+ PAGE = 10_000 # windows per read; keeps the cursor closed between yields
104
+ def __init__(self, db, name):
105
+ store_attr(); self.n, self._w = 0, 0
106
+ with write_txn(db):
107
+ db.conn.execute(f'drop table if exists main.{name}') # left behind by older versions
108
+ db.conn.execute(f'create temp table if not exists {name} (w integer, e text)')
109
+ db.conn.execute(f'delete from {name}')
110
+ def extend(self, wins):
111
+ rows = [(self._w + i, e) for i, w in enumerate(wins) for e in w if e]
112
+ if not rows: self._w += len(wins); self.n += len(wins); return
113
+ with write_txn(self.db): self.db.conn.executemany(f'insert into {self.name} values (?,?)', rows)
114
+ self._w += len(wins); self.n += len(wins)
115
+ def entity_counts(self):
116
+ 'entity -> windows containing it. Bounded by vocabulary, which saturates; safe in memory.'
117
+ with db_lock(self.db):
118
+ return dict(self.db.conn.execute(f'select e, count(*) from {self.name} group by e'))
119
+ def pair_counts(self, hub, min_n):
120
+ """Co-occurrence count per unordered pair, aggregated on disk. Materialised: the caller
121
+ builds edges between rows, and an open cursor makes the connection busy for that whole walk."""
122
+ c = self.db.conn
123
+ with write_txn(self.db):
124
+ c.execute(f'create index if not exists {self.name}_w on {self.name}(w)')
125
+ c.execute(f'create temp table if not exists {self.name}_hub (e text primary key)')
126
+ c.execute(f'delete from {self.name}_hub')
127
+ if hub: c.executemany(f'insert or ignore into {self.name}_hub values (?)', [(e,) for e in hub])
128
+ with db_lock(self.db): return c.execute(f'''select a.e, b.e, count(*) c from {self.name} a
129
+ join {self.name} b on a.w = b.w and a.e < b.e
130
+ where a.e not in (select e from {self.name}_hub)
131
+ and b.e not in (select e from {self.name}_hub)
132
+ group by a.e, b.e having c >= {int(min_n)}''').fetchall()
133
+ def __len__(self): return self.n
134
+ def __iter__(self):
135
+ 'One page of whole windows at a time: a cursor left open across a yield holds the connection.'
136
+ last = -1
137
+ while True:
138
+ with db_lock(self.db):
139
+ ws = [r[0] for r in self.db.conn.execute(
140
+ f'select distinct w from {self.name} where w > ? order by w limit {self.PAGE}', (last,)).fetchall()]
141
+ if not ws: return
142
+ rows = self.db.conn.execute(f'select w, e from {self.name} where w between ? and ? order by w',
143
+ (ws[0], ws[-1])).fetchall()
144
+ w, acc = None, set()
145
+ for wi, e in rows:
146
+ if wi != w:
147
+ if w is not None: yield acc
148
+ w, acc = wi, set()
149
+ acc.add(e)
150
+ if w is not None: yield acc
151
+ last = ws[-1]
152
+ def drop(self):
153
+ with write_txn(self.db):
154
+ self.db.conn.execute(f'drop table if exists temp.{self.name}')
155
+ self.db.conn.execute(f'drop table if exists temp.{self.name}_hub')
156
+
157
+ def build_graph(db, # Database with a chunk store
158
+ chunks, # chunk dicts ({'content','metadata'}) as returned by dir2chunks/pkg2chunks
159
+ store='store', # chunk store name
160
+ prefix=None, # graph table prefix
161
+ terms_fn=None, # (text, topk) -> terms, replacing yake (see `sanskrit_terms`)
162
+ emb_fn=None, # embedder for entity names (required for resolve_entities)
163
+ code=True, # extract AST symbols from code chunks
164
+ prose=True, # extract surfaces from prose chunks
165
+ cooc=True, # also write PMI co-occurrence edges over sentence windows
166
+ min_n=2, # min co-occurrence count
167
+ min_npmi=0.15, # min normalized PMI
168
+ max_df=0.4, # drop entities present in >max_df of windows
169
+ max_degree=48, # max cooc edges kept per node
170
+ batch:int=None, # chunks per flush; keeps windows on disk instead of in memory
171
+ n_workers:int=None): # extraction workers; 0 is serial, None picks by queue size
172
+ '''Extract entities + mentions + edges (exact for code, PMI co-occurrence for prose) from chunks.
173
+
174
+ `batch` is what makes a large corpus finish. Left as None everything accumulates in memory for
175
+ the whole call, which is fine for a package or a few thousand pages and is not fine for a
176
+ corpus: windows are one per sentence and never stop arriving, so memory tracks the corpus with
177
+ no bound. Set it and mentions are flushed every `batch` chunks and windows go to a scratch
178
+ table, which `_pmi_edges` reads back twice exactly as it would a list. The edges are identical
179
+ either way — the batched path is a different place to keep the same numbers.
180
+
181
+ `n_workers` spreads extraction across processes, which is where a build spends 88% of its time.
182
+ Use it with `batch` large enough to be worth a pool: the queue is drained once per batch, so a
183
+ small `batch` pays pool startup repeatedly for very little work.'''
184
+ g = db.get_graph(store, prefix)
185
+ ents, mens, edges = {}, {}, {}
186
+ wins = _WindowStore(db, f'{g.prefix}_win_scratch') if batch else []
187
+ def ent(name, kind):
188
+ 'Register an entity by canonical name, returning its hash id.'
189
+ n = _norm(name)
190
+ if not n: return None
191
+ i = content_id(n)
192
+ e = ents.setdefault(i, dict(content=n, kind=kind, freq=0, canon=i))
193
+ e['freq'] += 1
194
+ return i
195
+ def men(cid, eid, surface):
196
+ if not (cid and eid): return
197
+ m = mens.setdefault((cid, eid), dict(chunk_id=cid, entity_id=eid, surface=surface, n=0))
198
+ m['n'] += 1
199
+ def edge(s, d, rel, w=1.0):
200
+ if not (s and d) or s == d: return
201
+ e = edges.setdefault((s, d, rel), dict(src=s, dst=d, rel=rel, weight=0.0, n=0))
202
+ e['weight'] += w; e['n'] += 1
203
+ n_mens = 0
204
+ def flush_mentions():
205
+ 'Mentions are complete once their chunk is processed, so they need not wait for the corpus.'
206
+ nonlocal n_mens
207
+ if not mens: return
208
+ upsert_all(g.mentions, mens.values(), ('chunk_id','entity_id'))
209
+ n_mens += len(mens); mens.clear()
210
+
211
+ pend = [] # windows waiting for the next flush
212
+ def add_wins(ws):
213
+ if batch: pend.extend(ws)
214
+ else: wins.extend(ws)
215
+
216
+ prose_q, pool = [], None
217
+ def prose_wins():
218
+ '''Windows per queued prose chunk, over a process pool once there are enough to pay for one.
219
+ Order is preserved by both pools, and the reduce depends on it: `ents.setdefault` keeps the
220
+ `kind` of an entity's *first* mention, so a reordered stream would relabel entities.'''
221
+ nonlocal pool
222
+ nw = 0 if terms_fn is not None else _n_workers(len(prose_q), n_workers)
223
+ if nw and nw > 1:
224
+ if pool is None: pool = _pool(nw)
225
+ # both sides materialised: `drain_prose` clears the queue these are drawn from
226
+ cids, txts = [c for c, _ in prose_q], [t for _, t in prose_q]
227
+ return zip(cids, pool.map(_prose_job, txts))
228
+ return ((cid, prose_windows(txt, terms_fn=terms_fn)) for cid, txt in prose_q)
229
+
230
+ def drain_prose():
231
+ 'Turn the queued prose chunks into entities, mentions and windows, then clear the queue.'
232
+ for cid, wl in prose_wins():
233
+ for win in wl:
234
+ w = set()
235
+ for surf, kind in win:
236
+ i = ent(surf, kind)
237
+ if i: men(cid, i, surf); w.add(i)
238
+ if len(w) > 1: add_wins([w])
239
+ prose_q.clear()
240
+
241
+ n_seen = 0
242
+ try:
243
+ for c in ([chunks] if isinstance(chunks, dict) else chunks):
244
+ txt = c.get('content')
245
+ if not (txt and txt.strip()): continue
246
+ cid = c.get('id') or content_id(txt)
247
+ if code and _is_code(c):
248
+ dname, calls, imps = code_entities(c)
249
+ did = ent(dname, 'symbol') if dname else None
250
+ if did: men(cid, did, dname)
251
+ w = {did} if did else set()
252
+ for nm in calls:
253
+ i = ent(nm, 'symbol')
254
+ men(cid, i, nm)
255
+ edge(did, i, 'calls')
256
+ w.add(i)
257
+ for nm in imps:
258
+ i = ent(nm, 'module')
259
+ men(cid, i, nm)
260
+ edge(did, i, 'imports')
261
+ w.add(i)
262
+ if len(w) > 1: add_wins([w - {None}])
263
+ elif prose: prose_q.append((cid, txt))
264
+ n_seen += 1
265
+ if batch and n_seen % batch == 0:
266
+ drain_prose()
267
+ wins.extend(pend)
268
+ pend.clear()
269
+ flush_mentions()
270
+ drain_prose()
271
+ finally:
272
+ if pool is not None: pool.shutdown()
273
+ if batch: wins.extend(pend); pend.clear()
274
+ rows = list(ents.values())
275
+ if cooc and len(wins):
276
+ for e in _pmi_edges(wins, min_n, min_npmi, max_df, max_degree): edges[(e['src'], e['dst'], e['rel'])] = e
277
+ n_wins = len(wins)
278
+ if batch: wins.drop()
279
+ with write_txn(db):
280
+ if rows:
281
+ if emb_fn: process_content(g.entities, rows, embed=True, emb_fn=emb_fn)
282
+ else: g.entities.insert_all(rows, upsert=True, hash_id='id', hash_id_columns=['content'])
283
+ if mens:
284
+ upsert_all(g.mentions, mens.values(), ('chunk_id','entity_id')); n_mens += len(mens)
285
+ if edges: upsert_all(g.edges, edges.values(), ('src','dst','rel'))
286
+ if emb_fn and rows: g.entities.rebuild_index()
287
+ return dict(entities=len(rows), mentions=n_mens, edges=len(edges), windows=n_wins)
288
+
289
+
290
+ # %% ../nbs/01_build.ipynb #f1e0bc8fd1ac5d91
291
+ _EXACT_KINDS = ('symbol', 'module', 'topic') # names that are already canonical — never merge these
292
+
293
+ def _uf_find(par, x):
294
+ while par[x] != x: par[x] = par[par[x]]; x = par[x]
295
+ return x
296
+
297
+ def _uf_union(par, rank, a, b, name=None, ok=None, members=None, max_check=32):
298
+ 'Merge two groups. With `name`/`ok`, only if the merged group stays a **clique** under `ok`.'
299
+ ra, rb = _uf_find(par, a), _uf_find(par, b)
300
+ if ra == rb: return False
301
+ if name is not None and ok is not None:
302
+ A = (members.get(ra, [ra]) if members else [ra])[:max_check]
303
+ B = (members.get(rb, [rb]) if members else [rb])[:max_check]
304
+ if not all(ok(name[x], name[y]) for x in A for y in B): return False
305
+ if rank[ra] < rank[rb]: ra, rb = rb, ra
306
+ par[rb] = ra
307
+ if members is not None: members.setdefault(ra, [ra]).extend(members.pop(rb, [rb]))
308
+ return True
309
+
310
+ def _ann_pairs(tbl, rows, k=8, dtype=np.float16):
311
+ '''`(id, neighbour_id, distance)` for every embedded row, from one batched HNSW probe.
312
+
313
+ usearch searches a matrix of queries across its own thread pool, so asking it once for 8,487
314
+ vectors is not the same work as asking it 8,487 times: the per-entity loop also ran one
315
+ `rowid IN (...)` query per entity, and the two together were 10.3s of a 23.0s resolve. The
316
+ candidate set is unchanged — same k, same neighbours, same distances.'''
317
+ idx_ = tbl.db.get_index(tbl.name)
318
+ emb = [r for r in rows if r['embedding']]
319
+ if not emb or not idx_.size: return
320
+ key = {r['rowid']: r for r in tbl.db.q(f'select {rowid_sel()}, id, content from {tbl.name}')}
321
+ res = idx_.search(np.stack([np.frombuffer(r['embedding'], dtype=dtype) for r in emb]),
322
+ count=min(k, idx_.size))
323
+ ks, ds = np.atleast_2d(res.keys), np.atleast_2d(res.distances)
324
+ for r, kr, dr in zip(emb, ks, ds):
325
+ for kk, dd in zip(np.atleast_1d(kr).tolist(), np.atleast_1d(dr).tolist()):
326
+ if (o := key.get(int(kk))): yield r, o, float(dd)
327
+
328
+ def _lexical_pairs(name, max_group=60):
329
+ '''Candidate merge pairs by shared-token blocking — catches containment variants
330
+
331
+ Tokens are walked in sorted order, which is what makes a resolve reproducible. `_toks` returns
332
+ a frozenset, so its iteration order follows string hashes and therefore `PYTHONHASHSEED`; that
333
+ decided the insertion order of `inv`, which decided the order pairs were proposed in, and
334
+ `_uf_union` only accepts a merge that keeps the group a clique — an order-dependent test. Two
335
+ resolves of the *same* database in two processes came back with different partitions and merge
336
+ counts drifting over a range of three, which looked like HNSW noise and was not: hold the seed
337
+ still and both the old code and the new one are exactly reproducible, and `verify_ann_probe`
338
+ says the ANN candidates were stable the whole time.'''
339
+ inv = {}
340
+ for i, s in name.items():
341
+ for t in sorted(_toks(s)):
342
+ if len(t) > 2: inv.setdefault(t, []).append(i)
343
+ seen = set()
344
+ for ids in inv.values():
345
+ if len(ids) < 2: continue
346
+ if len(ids) <= max_group: pairs = ((ids[a], ids[b]) for a in range(len(ids)) for b in range(a+1, len(ids)))
347
+ else:
348
+ srt = sorted(ids, key=lambda i: name[i])
349
+ pairs = ((srt[a], srt[b]) for a in range(len(srt)) for b in range(a+1, min(a+max_group, len(srt))))
350
+ for x, y in pairs:
351
+ p = (x, y) if x < y else (y, x)
352
+ if p not in seen: seen.add(p); yield p
353
+
354
+ def resolve_entities(db, # Database
355
+ store='store', # chunk store the graph belongs to
356
+ prefix=None, # graph table prefix
357
+ thresh=0.18, # max ANN distance for a merge candidate
358
+ lex=0.34, # min token Jaccard for the lexical guard
359
+ k=8, # ANN neighbours considered per entity
360
+ lexical=True, # also block on shared tokens (works without embeddings)
361
+ max_group=60, # skip token groups bigger than this in the lexical pass
362
+ skip_kinds=_EXACT_KINDS, # kinds whose names are already canonical
363
+ dtype=np.float16):
364
+ 'Merge near-duplicate entities: ANN + shared-token candidates, both gated by the lexical guard.'
365
+ g = db.get_graph(store, prefix)
366
+ allr = L(g.entities(select=f'{rowid_sel()}, id, content, freq, embedding, kind, canon'))
367
+ rows = allr.filter(lambda r: r['kind'] not in set(skip_kinds or ()))
368
+ if len(rows) < 2:
369
+ return dict(merged=0, by_ann=0, by_lexical=0, edges=len(list(g.edges())),
370
+ entities=len(allr), resolvable=len(rows), canonical=len(allr))
371
+ par = {r['id']: r['id'] for r in rows}
372
+ rank = {r['id']: (r['freq'] or 0) for r in rows}
373
+ name = {r['id']: r['content'] for r in rows}
374
+ ann_m = lex_m = 0
375
+ guard, members = (lambda x, y: _lex_ok(x, y, lex)), {}
376
+ for r, h, dist in _ann_pairs(g.entities, rows, k, dtype):
377
+ oid = h.get('id')
378
+ if not oid or oid == r['id'] or oid not in par: continue
379
+ if dist > thresh: continue
380
+ if not _lex_ok(r['content'], h['content'], lex): continue
381
+ if _uf_union(par, rank, r['id'], oid, name, guard, members): ann_m += 1
382
+ if lexical:
383
+ for a, b in _lexical_pairs(name, max_group):
384
+ if _lex_ok(name[a], name[b], lex) and _uf_union(par, rank, a, b, name, guard, members): lex_m += 1
385
+ upd = [(_uf_find(par, i), i) for i in par]
386
+ was = {r['id']: r['canon'] for r in rows}
387
+ chg = [(c, i) for c, i in upd if c != was.get(i)]
388
+ if chg:
389
+ with write_txn(db):
390
+ db.conn.cursor().executemany(f'update {g.entities.name} set canon=? where id=?', chg)
391
+ canon = {i: c for c, i in upd}
392
+ n_edges = _collapse_edges(db, g, canon)
393
+ skipped = len(allr) - len(rows)
394
+ return dict(merged=ann_m+lex_m, by_ann=ann_m, by_lexical=lex_m, edges=n_edges,
395
+ entities=len(allr), resolvable=len(rows),
396
+ canonical=len({c for c, _ in upd}) + skipped)
397
+
398
+ def _collapse_edges(db, g, canon):
399
+ 'Rewrite edge endpoints onto canonical ids. Traversal reads src/dst straight from the table,'
400
+ rows = list(g.edges())
401
+ if not rows: return 0
402
+ agg = {}
403
+ for r in rows:
404
+ s, d = canon.get(r['src'], r['src']), canon.get(r['dst'], r['dst'])
405
+ if s == d: continue
406
+ if s > d and r['rel'] == 'cooc': s, d = d, s # cooc is symmetric; keep one direction
407
+ k = (s, d, r['rel'])
408
+ a = agg.setdefault(k, dict(src=s, dst=d, rel=r['rel'], weight=0.0, n=0))
409
+ a['weight'] = max(a['weight'], r['weight'] or 0.0); a['n'] += (r['n'] or 0)
410
+ with write_txn(db):
411
+ g.edges.delete_where()
412
+ upsert_all(g.edges, agg.values(), ('src','dst','rel'))
413
+ return len(agg)
414
+
415
+
416
+ # %% ../nbs/01_build.ipynb #dd02940510f58745
417
+ def cooccur_edges(db, # Database
418
+ store='store', # chunk store
419
+ prefix=None, # graph table prefix
420
+ min_n=2, # min co-occurrence count
421
+ min_npmi=0.15, # min normalized PMI (prunes stopword-ish hub nodes)
422
+ max_df=0.4, # drop entities present in >max_df of chunks
423
+ max_degree=48, # keep only the strongest edges per node
424
+ rel='cooc',
425
+ use_canon=True): # collapse to canonical ids from resolve_entities
426
+ 'Rebuild co-occurrence edges from the stored mentions, using the chunk as the window.'
427
+ g = db.get_graph(store, prefix)
428
+ canon = {}
429
+ if use_canon:
430
+ canon = {r['id']: (r['canon'] or r['id']) for r in g.entities(select='id, canon')}
431
+ cid_ents = {}
432
+ for m in g.mentions(select='chunk_id, entity_id'):
433
+ e = canon.get(m['entity_id'], m['entity_id'])
434
+ cid_ents.setdefault(m['chunk_id'], set()).add(e)
435
+ out = _pmi_edges(list(cid_ents.values()), min_n, min_npmi, max_df, max_degree, rel)
436
+ if out: upsert_all(g.edges, out, ('src','dst','rel'))
437
+ return len(out)
438
+
@@ -0,0 +1,141 @@
1
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/00_entities.ipynb.
2
+
3
+ # %% auto #0
4
+ __all__ = ['code_entities', 'prose_windows', 'text_entities']
5
+
6
+ # %% ../nbs/00_entities.ipynb #ab82e9d12af8898f
7
+ import ast, math, re, sys
8
+ from functools import lru_cache
9
+ import numpy as np
10
+ from fastcore.all import AttrDict, L, Path, chunked, defaults, first, ifnone, merge, patch, store_attr
11
+ from fastcore.parallel import ProcessPoolExecutor
12
+ from fastlite import Database
13
+ from apswutils.db import Table
14
+ from multiprocessing import get_context
15
+ from litesearch.core import (sql_in, rowid_sel, content_id, NP_DTYPE, process_content, write_txn, db_lock,
16
+ upsert_all, rrf_all)
17
+ from litesearch.topics import get_graph
18
+ from litesearch.utils import hash_embed
19
+
20
+ # %% ../nbs/00_entities.ipynb #673ffc3a5da7b038
21
+ _DET = re.compile(r'^(the|a|an|this|that|these|those|its|their|our|your|his|her)\s+', re.I)
22
+ _WS = re.compile(r'\s+')
23
+ _PRON = {'it','its','we','our','they','their','he','she','you','i','me','us','them','this','that',
24
+ 'these','those','which','who','what','there','here','one','ones','something','anything'}
25
+ _TOK = re.compile(r'[a-z0-9]+')
26
+ _NUM = re.compile(r'\d+')
27
+
28
+ # %% ../nbs/00_entities.ipynb #109cd07acfcb6b94
29
+ def _norm(s):
30
+ 'Canonical surface form for a mention; None when the phrase is not entity-like.'
31
+ if not s: return None
32
+ s = _WS.sub(' ', s).strip().strip('.,;:!?()[]{}"\'`')
33
+ s = _DET.sub('', s).strip()
34
+ s = re.sub(r"'s$", '', s).strip()
35
+ if not (2 <= len(s) <= 60): return None
36
+ if len(s.split()) > 5: return None
37
+ if s.lower() in _PRON: return None
38
+ if not any(c.isalpha() for c in s): return None
39
+ return s.lower()
40
+
41
+ @lru_cache(maxsize=1<<17)
42
+ def _toks(s):
43
+ 'Tokens for the lexical guard. UAX#29 treats `_` as a word joiner, so `fts_search` stays one'
44
+ try: from apsw.unicode import word_iter, casefold
45
+ except ImportError: return frozenset(_TOK.findall(s.lower()))
46
+ return frozenset(casefold(t) for t in word_iter(s or '') if any(c.isalnum() for c in t))
47
+
48
+ @lru_cache(maxsize=1<<17)
49
+ def _acr(s): return ''.join(w[0] for w in s.split() if w)
50
+
51
+ @lru_cache(maxsize=1<<17)
52
+ def _nums(s): return frozenset(_NUM.findall(s))
53
+
54
+ def _sentences(text):
55
+ 'UAX#29 sentence split via apsw; falls back to the whole text as one window.'
56
+ try: from apsw.unicode import sentence_iter
57
+ except ImportError: return [text]
58
+ return [s for s in (x.strip() for x in sentence_iter(text)) if s]
59
+
60
+ def _jac(a, b):
61
+ A, B = _toks(a), _toks(b)
62
+ u = len(A) + len(B) - len(A & B)
63
+ return len(A & B) / u if u else 0.0
64
+
65
+ # %% ../nbs/00_entities.ipynb #7710055a5e8346fe
66
+ def _lex_ok(a, b, lex=0.34, cover=0.5):
67
+ '''Lexical guard on a proposed merge.'''
68
+ if a == b: return True
69
+ A, B = _toks(a), _toks(b)
70
+ ok = False
71
+ if A and B:
72
+ inter = len(A & B)
73
+ ok = ((inter == min(len(A), len(B)) and inter/max(len(A), len(B)) >= cover)
74
+ or inter/(len(A) + len(B) - inter) >= lex)
75
+ if not ok and not (_acr(a) == b.lower() or _acr(b) == a.lower()): return False
76
+ return _nums(a) == _nums(b)
77
+
78
+ # %% ../nbs/00_entities.ipynb #5952ad1336601711
79
+ _PY_SKIP = {'self','cls','super','print','len','str','int','float','bool','list','dict','set','tuple',
80
+ 'range','enumerate','zip','map','filter','isinstance','getattr','setattr','hasattr','type',
81
+ 'format','join','append','get','items','keys','values','open','sorted','sum','min','max'}
82
+
83
+ def _def_name(chunk):
84
+ 'Symbol defined by a code chunk, from pyparse metadata or the chunk source itself.'
85
+ md = chunk.get('metadata') or {}
86
+ if isinstance(md, dict) and md.get('name'): return md['name']
87
+ try: tree = ast.parse(chunk['content'])
88
+ except SyntaxError: return None
89
+ n = first(tree.body, lambda x: isinstance(x, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)))
90
+ return getattr(n, 'name', None)
91
+
92
+ def code_entities(chunk):
93
+ 'Exact symbols for a python chunk: (defined, called, imported). AST-derived, no model.'
94
+ try: tree = ast.parse(chunk['content'])
95
+ except (SyntaxError, ValueError): return None, L(), L()
96
+ calls, imps = L(), L()
97
+ for n in ast.walk(tree):
98
+ if isinstance(n, ast.Call):
99
+ f = n.func
100
+ if isinstance(f, ast.Name): calls.append(f.id)
101
+ elif isinstance(f, ast.Attribute): calls.append(f.attr)
102
+ elif isinstance(n, ast.Import):
103
+ imps += [a.name.split('.')[0] for a in n.names]
104
+ elif isinstance(n, ast.ImportFrom):
105
+ if n.module: imps.append(n.module.split('.')[0])
106
+ keep = lambda s: s and s not in _PY_SKIP and not s.startswith('_') and len(s) > 2
107
+ return _def_name(chunk), calls.filter(keep).unique(), imps.filter(keep).unique()
108
+
109
+ # %% ../nbs/00_entities.ipynb #d337238a913ea5d3
110
+ def _yake_terms(text, topk=12):
111
+ 'Keyphrases via yake — zero model, zero labels, and the default prose extractor.'
112
+ try: from yake import KeywordExtractor
113
+ except ImportError: return L()
114
+ try: return L(KeywordExtractor(n=3, top=topk).extract_keywords(text)).map(lambda kv: kv[0])
115
+ except Exception: return L()
116
+
117
+ def prose_windows(text, # chunk text
118
+ topk=12, # keyphrase count
119
+ terms_fn=None): # (text, topk) -> terms; None -> yake
120
+ 'Entity surfaces grouped into co-occurrence windows — one per sentence.'
121
+ terms = (terms_fn or _yake_terms)(text, topk)
122
+ if not terms: return L()
123
+ # the term list is fixed for the whole chunk, so it is lowercased once here rather than once
124
+ # per sentence — `t.lower()` sat in the inner loop and ran topk times for every sentence
125
+ low = [(t, t.lower()) for t in terms]
126
+ wins = []
127
+ for s in _sentences(text):
128
+ sl = s.lower()
129
+ hit = L([(t, 'keyphrase') for t, tl in low if tl in sl])
130
+ if hit: wins.append(hit)
131
+ return L(wins)
132
+
133
+ def text_entities(text, **kw):
134
+ 'Entity surfaces for a prose chunk, flattened across windows. Returns L of (surface, kind).'
135
+ seen, out = set(), L()
136
+ for w in prose_windows(text, **kw):
137
+ for s, k in w:
138
+ n = _norm(s)
139
+ if n and n not in seen: seen.add(n); out.append((s, k))
140
+ return out
141
+
@@ -0,0 +1,164 @@
1
+ # AUTOGENERATED! DO NOT EDIT! File to edit: ../nbs/02_search.ipynb.
2
+
3
+ # %% auto #0
4
+ __all__ = ['graph_stats']
5
+
6
+ # %% ../nbs/02_search.ipynb #ab82e9d12af8898f
7
+ import ast, math, re, sys
8
+ from functools import lru_cache
9
+ import numpy as np
10
+ from fastcore.all import AttrDict, L, Path, chunked, defaults, first, ifnone, merge, patch, store_attr
11
+ from fastcore.parallel import ProcessPoolExecutor
12
+ from fastlite import Database
13
+ from apswutils.db import Table
14
+ from multiprocessing import get_context
15
+ from litesearch.core import (sql_in, rowid_sel, content_id, NP_DTYPE, process_content, write_txn, db_lock,
16
+ upsert_all, rrf_all)
17
+ from litesearch.topics import get_graph
18
+ from litesearch.utils import hash_embed
19
+ from .build import build_graph, resolve_entities, cooccur_edges
20
+
21
+ # %% ../nbs/02_search.ipynb #a380070d2dd9d9da
22
+ def _adjacency(g, nodes, hops=2, max_nodes=4000):
23
+ '''BFS the edge table out to `hops` from `nodes`; returns an undirected dict-of-dict adjacency.
24
+
25
+ Read as raw tuples rather than through the table wrapper. A two-hop walk off twelve seeds pulls
26
+ ~16k edges, and fastlite turns each one into a dict with a description lookup per row — 326k
27
+ dicts over twenty queries, of which this needs three columns and none of the keys.'''
28
+ adj, seen, frontier = {}, set(nodes), set(nodes)
29
+ con, tbl = g.edges.db.conn, g.edges.name
30
+ for _ in range(max(hops, 0)):
31
+ if not frontier or len(seen) >= max_nodes: break
32
+ fl = list(frontier)
33
+ rows = []
34
+ for b in chunked(fl, 400):
35
+ rows += con.execute(f"select src, dst, weight from {tbl} "
36
+ f"where {sql_in('src', b)} OR {sql_in('dst', b)}").fetchall()
37
+ nxt = set()
38
+ for s, d, w in rows:
39
+ w = w or 1.0
40
+ adj.setdefault(s, {})[d] = max(adj.setdefault(s, {}).get(d, 0.0), w)
41
+ adj.setdefault(d, {})[s] = max(adj.setdefault(d, {}).get(s, 0.0), w)
42
+ for x in (s, d):
43
+ if x not in seen: nxt.add(x)
44
+ seen |= nxt
45
+ frontier = nxt
46
+ return adj
47
+
48
+ def _csr(adj, order):
49
+ '''`(src, dst, w)` index arrays for `adj`, row-normalised — the transition matrix as three arrays.
50
+
51
+ Built once and reused by every power iteration, which is the whole point: the walk itself is
52
+ then twelve numpy passes instead of twelve nested python loops over the same unchanging edges.'''
53
+ src, dst, wt = [], [], []
54
+ for u, nb in adj.items():
55
+ iu, s = order[u], (sum(nb.values()) or 1.0)
56
+ for v, w in nb.items(): src.append(iu); dst.append(order[v]); wt.append(w/s)
57
+ return (np.array(src, dtype=np.intp), np.array(dst, dtype=np.intp),
58
+ np.array(wt, dtype=np.float64))
59
+
60
+ def _ppr(adj, seeds, damping=0.85, iters=12):
61
+ '''Personalized PageRank over a dict-of-dict adjacency.
62
+
63
+ The iteration is `r <- d * Wᵀr + (1-d) * p0` and nothing but `r` changes between rounds, so the
64
+ edges are flattened into index arrays once and each round becomes a gather plus a `bincount` —
65
+ the same sum, done by numpy instead of by a dict lookup per edge per round. Dangling nodes still
66
+ drop their mass rather than redistributing it, exactly as the dict version did.'''
67
+ if not seeds: return {}
68
+ order = {}
69
+ for u, nb in adj.items():
70
+ if u not in order: order[u] = len(order)
71
+ for v in nb:
72
+ if v not in order: order[v] = len(order)
73
+ for k in seeds:
74
+ if k not in order: order[k] = len(order)
75
+ n = len(order)
76
+ p0 = np.zeros(n)
77
+ tot = sum(seeds.values()) or 1.0
78
+ for k, v in seeds.items(): p0[order[k]] = v/tot
79
+ src, dst, wt = _csr(adj, order)
80
+ wt = wt * damping
81
+ r, rest = p0, (1-damping)*p0
82
+ for _ in range(iters):
83
+ r = np.bincount(dst, weights=r[src]*wt, minlength=n) + rest
84
+ return {k: float(r[i]) for k, i in order.items() if r[i]}
85
+
86
+ # %% ../nbs/02_search.ipynb #66f98425ed3e448
87
+ def _canon_mentions(g, # graph tables from `get_graph`
88
+ col, # 'chunk_id' or 'entity_id' — the side being filtered
89
+ vals, # values to filter on
90
+ use_canon=True, # resolve entity_id to its canonical id
91
+ batch=400):
92
+ '''`(chunk_id, entity_id)` for the matching mentions, with `entity_id` already canonicalised.'''
93
+ con, mn, en = g.mentions.db.conn, g.mentions.name, g.entities.name
94
+ sel = (f'select m.chunk_id, coalesce(nullif(e.canon, \'\'), m.entity_id) from {mn} m '
95
+ f'left join {en} e on e.id = m.entity_id') if use_canon else \
96
+ f'select m.chunk_id, m.entity_id from {mn} m'
97
+ out = []
98
+ for b in chunked(vals, batch):
99
+ out += con.execute(f"{sel} where {sql_in('m.'+col, b)}").fetchall()
100
+ return out
101
+
102
+ # %% ../nbs/02_search.ipynb #fba9515fca9c5b56
103
+ def _leg(db, g, seeds_from, cols, table_name, limit, hops, damping, iters, use_canon):
104
+ 'Chunks the walk reaches, ranked by PPR mass. Empty at every dead end, which RRF then ignores.'
105
+ if not seeds_from: return []
106
+ # canon is resolved by joining the mentions being read rather than by loading the whole entity
107
+ # table into a dict. Every query paid O(entities) for a map it used O(mentions-of-12-chunks) of,
108
+ # which is the one cost here that grew with the corpus rather than with the query.
109
+ seeds = {}
110
+ for _, e in _canon_mentions(g, 'chunk_id', seeds_from, use_canon): seeds[e] = seeds.get(e, 0.0) + 1.0
111
+ if not seeds: return []
112
+ mass = _ppr(_adjacency(g, set(seeds), hops), seeds, damping, iters)
113
+ eids = [e for e, m in sorted(mass.items(), key=lambda kv: -kv[1])[:200] if m > 0]
114
+ if not eids: return []
115
+ inv = {}
116
+ for cid, e in _canon_mentions(g, 'entity_id', eids, use_canon):
117
+ inv[cid] = inv.get(cid, 0.0) + mass.get(e, 0.0)
118
+ if not inv: return []
119
+ ranked = sorted(inv.items(), key=lambda kv: -kv[1])[:limit*3]
120
+ sel = ','.join([rowid_sel() if c == 'rowid' else c for c in cols])
121
+ rows = {r['id']: r for r in db.t[table_name](select=sel, where=sql_in('id', [c for c, _ in ranked]))}
122
+ return [rows[c] for c, _ in ranked if c in rows]
123
+
124
+ @patch
125
+ def graph_search(self:Database,
126
+ q:str, # query string
127
+ emb:bytes, # query embedding
128
+ columns:list=None, # columns to return
129
+ limit:int=20, # max results
130
+ table_name='store', # chunk store
131
+ prefix=None, # graph table prefix
132
+ seed_n:int=12, # hybrid hits used to seed the graph walk
133
+ hops:int=2, # edge-table BFS depth
134
+ damping:float=0.85, # PPR damping
135
+ iters:int=10, # PPR iterations
136
+ graph_w:float=0.5, # weight of the graph leg, low by default. See the docstring.
137
+ rrf_k:int=60,
138
+ use_canon=True,
139
+ **kw): # forwarded to Database.search
140
+ 'Hybrid search plus a graph leg: PPR over the entity graph seeded by the top hybrid hits.'
141
+ g = self.get_graph(table_name, prefix)
142
+ cols = list(columns or [])
143
+ if 'rowid' not in cols: cols = ['rowid'] + cols
144
+ if 'id' not in cols: cols = cols + ['id']
145
+ base = self.search(q, emb, columns=cols, limit=max(seed_n*3, limit), table_name=table_name,
146
+ rrf=False, **kw)
147
+ if not base: return []
148
+ fts, vec = base['fts'], base['vec']
149
+ seeds = [r['id'] for r in rrf_all([fts, vec], rrf_k, seed_n) if r.get('id')]
150
+ leg = _leg(self, g, seeds, cols, table_name, limit, hops, damping, iters, use_canon)
151
+ return rrf_all([fts, vec, leg], rrf_k, limit, weights=[1.0, 1.0, graph_w])
152
+
153
+
154
+ # %% ../nbs/02_search.ipynb #96b2124dafe08d01
155
+ def graph_stats(db, store='store', prefix=None):
156
+ 'Row counts and top-degree nodes for a built graph.'
157
+ g = db.get_graph(store, prefix)
158
+ ne = first(db.q(f'select count(*) c from {g.entities.name}'))['c']
159
+ nm = first(db.q(f'select count(*) c from {g.mentions.name}'))['c']
160
+ ng = first(db.q(f'select count(*) c from {g.edges.name}'))['c']
161
+ nc = first(db.q(f'select count(distinct canon) c from {g.entities.name}'))['c']
162
+ top = db.q(f'''select e.content, e.kind, count(*) d from {g.edges.name} g
163
+ join {g.entities.name} e on e.id=g.src group by g.src order by d desc limit 10''')
164
+ return dict(entities=ne, canonical=nc, mentions=nm, edges=ng, top_degree=top)