From 1b7bf16fd90d75d46f327dceafdf58024bc5a178 Mon Sep 17 00:00:00 2001 From: Reyaansh Sinha Date: Mon, 25 May 2026 19:23:57 -0400 Subject: [PATCH] Added NLI stuff --- .DS_Store | Bin 6148 -> 0 bytes .gitignore | 9 + CONTRIBUTING.md | 431 ++++++++++++++++++ Halgorithem/__init__.py | 3 +- .../__pycache__/__init__.cpython-312.pyc | Bin 231 -> 0 bytes .../claim_extraction.cpython-312.pyc | Bin 5642 -> 0 bytes .../__pycache__/confidence.cpython-312.pyc | Bin 2692 -> 0 bytes .../__pycache__/contradiction.cpython-312.pyc | Bin 8056 -> 0 bytes Halgorithem/__pycache__/core.cpython-312.pyc | Bin 25617 -> 0 bytes .../__pycache__/evidence.cpython-312.pyc | Bin 1347 -> 0 bytes .../__pycache__/math_utils.cpython-312.pyc | Bin 1523 -> 0 bytes Halgorithem/__pycache__/nlp.cpython-312.pyc | Bin 1462 -> 0 bytes .../__pycache__/retrieval.cpython-312.pyc | Bin 2371 -> 0 bytes .../source_quality.cpython-312.pyc | Bin 1621 -> 0 bytes .../__pycache__/temporal.cpython-312.pyc | Bin 2249 -> 0 bytes .../text_processing.cpython-312.pyc | Bin 6994 -> 0 bytes Halgorithem/__pycache__/web.cpython-312.pyc | Bin 3152 -> 0 bytes Halgorithem/checks/__init__.py | 5 + Halgorithem/checks/atomic.py | 56 +++ Halgorithem/checks/nli.py | 115 +++++ Halgorithem/checks/similarity.py | 32 ++ Halgorithem/checks/units.py | 106 +++++ Halgorithem/checks/utils.py | 17 + Halgorithem/confidence.py | 20 +- Halgorithem/contradiction.py | 160 ++++++- Halgorithem/core.py | 95 +++- Halgorithem/ingest.py | 36 ++ Halgorithem/main.py | 147 ++++++ Halgorithem/math_utils.py | 28 +- Halgorithem/model_runtime.py | 409 +++++++++++++++++ Halgorithem/models.py | 101 ++++ Halgorithem/nlp.py | 6 +- Halgorithem/process.py | 26 ++ Halgorithem/retrieval.py | 44 +- Halgorithem/text_processing.py | 2 +- Halgorithem/voting.py | 119 +++++ Halgorithem/web.py | 24 +- README (1).md | 242 ++++++++++ README.md | 208 --------- assets/Halgorithem.png | Bin 0 -> 59665 bytes engine.py | 13 +- main.py | 5 + pyproject.toml | 12 +- requirements.txt | 8 +- ...t_halgorithem.cpython-312-pytest-9.0.3.pyc | Bin 17175 -> 0 bytes tests/test_core_pipeline.py | 65 +++ tests/test_halgorithem.py | 101 ++++ tests/test_voting.py | 74 +++ tui.py | 9 +- 49 files changed, 2466 insertions(+), 262 deletions(-) delete mode 100644 .DS_Store create mode 100644 .gitignore create mode 100644 CONTRIBUTING.md delete mode 100644 Halgorithem/__pycache__/__init__.cpython-312.pyc delete mode 100644 Halgorithem/__pycache__/claim_extraction.cpython-312.pyc delete mode 100644 Halgorithem/__pycache__/confidence.cpython-312.pyc delete mode 100644 Halgorithem/__pycache__/contradiction.cpython-312.pyc delete mode 100644 Halgorithem/__pycache__/core.cpython-312.pyc delete mode 100644 Halgorithem/__pycache__/evidence.cpython-312.pyc delete mode 100644 Halgorithem/__pycache__/math_utils.cpython-312.pyc delete mode 100644 Halgorithem/__pycache__/nlp.cpython-312.pyc delete mode 100644 Halgorithem/__pycache__/retrieval.cpython-312.pyc delete mode 100644 Halgorithem/__pycache__/source_quality.cpython-312.pyc delete mode 100644 Halgorithem/__pycache__/temporal.cpython-312.pyc delete mode 100644 Halgorithem/__pycache__/text_processing.cpython-312.pyc delete mode 100644 Halgorithem/__pycache__/web.cpython-312.pyc create mode 100644 Halgorithem/checks/__init__.py create mode 100644 Halgorithem/checks/atomic.py create mode 100644 Halgorithem/checks/nli.py create mode 100644 Halgorithem/checks/similarity.py create mode 100644 Halgorithem/checks/units.py create mode 100644 Halgorithem/checks/utils.py create mode 100644 Halgorithem/ingest.py create mode 100644 Halgorithem/main.py create mode 100644 Halgorithem/model_runtime.py create mode 100644 Halgorithem/models.py create mode 100644 Halgorithem/process.py create mode 100644 Halgorithem/voting.py create mode 100644 README (1).md delete mode 100644 README.md create mode 100644 assets/Halgorithem.png create mode 100644 main.py delete mode 100644 tests/__pycache__/test_halgorithem.cpython-312-pytest-9.0.3.pyc create mode 100644 tests/test_core_pipeline.py create mode 100644 tests/test_voting.py diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 560b87154d75d301327489950965f31c7d07263b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKT}}cq5dJC~L3!cP$BhRd(Gx6xi6rU+9027fXacM*VDxRz;88q+7t?RrCb9*5 zG)8C0^y{`W)7g)7x&>gG>+u;-15lv~M#pS+h|G&_$$=lK5{->9!YxL)!vupZYaLJq zl!4#Ifb88Wck2KTc;Vjt+P{PDEFBGJ>5vvu9-7qphZXjKL)tF+n_z`yVEgu+?NdKn z%+ke_Hmn*fEknjw*t$`dEQ+zr=~k>3F3~_8bBqJq*TA-l3uZaP6e%*?GgGNe^P<;* z)UnMs+v64FU1khqg12@s3-%( + + # + Phase + Your goals + Definition of done + + + 1 + Join the community +
    +
  • Join our project by attending a Welcome Wagon meeting.
  • +
  • Decide which working group you'd like to work on based on your interests and experience.
  • +
+ + Consider this phase complete when you join a working group. + + + 2 + Adopt a template +
    +
  • Join a template working group.
  • +
  • Work with a template working group lead and your group to decide which template or tactic project you work on.
  • +
  • Assign yourself to the corresponding issue for that template or tactic. Note that this requires a GitLab account and you need to join the template repository as a member.
  • +
  • Use the Google Docs attached to that issue to compose the drafts of the template file deliverables.
  • +
+ + Consider this phase complete when you assign yourself to the issue tracking your template or tactic project. + + + 3 + Research the template

(Research phase) +
    +
  • Research examples and best practices for the content type you're creating a template for.
  • +
  • Collaborate and get early feedback on your research from your template working group lead and/or other templateers as part of a template writing working group.
  • +
+ + Consider this phase complete when you have finished a draft of your Resources document. + + + 4 + Draft the template deliverables

(Drafting phase) +
    +
  • Use the Google Docs attached to that issue to create the drafts for the rest of your deliverables in Google Docs: template file, template guide, template process.
  • +
  • Meet with your working group or writing partners to workshop on and get advice on your drafts while you are working on them.
  • +
+ + This phase completes when you schedule your drafts for review by other members of your working group or community. + + + 5 + Get feedback on drafts from the community

(Community review phase) +
    +
  • When your template deliverables are ready for review, your working group lead schedules 1-2 sessions in the community where other members of the project review and provide feedback on your template files deliverables.
  • +
  • Optional: Revise and refine your draft with subject matter experts and individuals beyond our community (such as Write the Docs or subject matter experts).
  • +
  • After making revisions, work with your template working group to determine when your draft is ready for the next phase.
  • +
+ + This phase completes after you incorporate the feedback into your draft and your drafts are ready for a deeper expert review. Ensure you have permission from the working group lead to move to the editorial review phase. + + + 6 + Get a review from the template editorial team

(Editorial team review phase) +
    +
  • When your draft is in a state where you feel it's ready, you can work with your working group lead to request an editorial team review. The template editorial team comprises experienced members of the project who review your template project in Google Docs to ensure that it: +
      +
    • Follows best practices for technical writing.
    • +
    • Has no major organization or structural issues.
    • +
    • Has no gaps or missing content.
    • +
    • Is consistent with our style guide.
    • +
    +
  • +
+ + This phase completes after you incorporate the feedback into your draft and your drafts are in a final state. Ensure you have permission from the working group lead to move to the final review phase. + + + + 7 + Submit a merge request

(Final review phase) +
    +
  • Convert drafts from Google Docs to Markdown.
  • +
  • Convert drafts from Google Docs to Markdown.
  • +
  • Ensure the Markdown format is clean. NOTE: This project uses EkLine.io to check content and format of Markdown files that you add or modify in the Merge Request.
  • +
  • Ensure the Markdown renders to HTML correctly.
  • +
  • Open a merge request against the templates repository. NOTE: You are responsible for educating yourself in how to use Git and GitLab, but you can consult your working group lead and fellow templateers for help.
  • +
  • Revise documents based on requests from merge request reviewers.
  • +
+ + This phase completes when the template merges into the repository. + + + 8 + Hand off to the Chronologue team for user testing

(Chronologue phase) +
    +
  • After completing the previous phase, your template is officially part of the Halgorithem Project and is available to our users.
  • +
  • After a template project is complete, our Chronologue working group creates an example of the template. While creating the example, the Chronologue group tests whether your template is user-friendly and can serve a real documentation project. If you're still involved in the community during this phase, these team members might reach out to you for feedback or to collaborate on possible template revisions.
  • +
  • As additional users try your template out in the wild, they may report usability issues or provide feedback for improvements to the template.
  • +
  • Either the Chronologue writer, the original template author, or another templateer evaluates feedback and incorporates it into future versions of the template. If extensive revisions arise, the template may need to go through the same previous template writing phases again.
  • +
+ + The Chronologue team considers this phase complete when they create an example for the template. + + + +Each phase has more depth in the remaining sections. + +## 1. Join the community + +To join our community, you need to register for a [Welcome Wagon meeting](https://thegooddocsproject.dev/welcome/). At this 1-hour orientation meeting, you get: + +* A brief overview of our project's goals and mission. +* A bit of information about our community and reasons to consider joining. +* An overview of our key initiatives and working groups that you might consider contributing to. +* An in-depth orientation to one working group of your choice. + +After registering for this meeting, you get an email with a link to join our Slack workspace. You are eligible to be a member of our repository after you attend a Welcome Wagon meeting. + +To become a full-fledged templateer, you need to join our communication channels so that you can talk to us: + +* **Slack** - Our Slack workspace is one of the primary means of communicating with members of our project. After joining our workspace, join the `#welcome` channel to introduce yourself. Consider also joining these Slack channels if you plan to work on a template or tactic project: + * `#templates` + * `#ask-a-community-manager` + * `#tech-requests` +* **Working groups** - We organize our project into several different working groups that meet on a regular basis to work on the project's key initiatives. One of the best ways to get started with our project is to join and meet with one of our working groups. See [The Halgorithem Project Working Groups](https://thegooddocsproject.dev/working-groups/) for a list of our current active groups. NOTE: If you plan to contribute to our project by writing templates, you must join one of the template working groups. +* **Weekly meetings** - The project leaders hold weekly meetings to discuss project-level decisions. Feel free to join one of these meetings to introduce yourself to the project leaders and discover next steps for getting involved in the project. See the [community calendar](https://thegooddocsproject.dev/community/#calendar) for meeting times. + +As you begin to join our project, remember that this is a project composed entirely of volunteers. We love to welcome new members, but want to be careful not to burn out our core project contributors. This level of mindfulness helps us ensure that we retain our project's capacity to produce high-quality work. As such, we ask that you respect the time of our project maintainers and contributors. +(And expect us to respect your time in return!) + +We expect all members of our project to be nice to each other and to follow our [Code of Conduct](https://thegooddocsproject.dev/code-of-conduct/) when interacting with other members of the Halgorithem Project. + +## 2. Adopt a template + +In this phase, you decide which template or tactic project you work on and assign yourself to the issue tracking that project. + +Be aware that: + +* Each template or tactic project relates to a corresponding issue in the templates repository. +* You use this issue to communicate the status of your template project as it moves through the different phases of the template writing process. +* The Halgorithem Project managers use a kanban board that shows all the issues for the current template projects. This tool allows the templateers and project stakeholders to track the overall progress of each template and assist templateers whose progress has stalled. + +Links: + +* [Template issues list](https://gitlab.com/tgdp/templates/-/issues) +* [Templates in progress kanban board](https://gitlab.com/tgdp/templates/-/boards/4801048) + +To adopt a template: + +1. Scroll through the list of available template and tactic issues and see if one interests you and/or matches your skill set. Alternatively, if you have an idea for a template or tactic project that doesn't yet have an issue, and you have the support of a template working group lead, you can create a new issue for your project. + +2. Assign yourself to the issue. Note that this requires a [GitLab account](https://gitlab.com/users/sign_up). You must attend a [Welcome Wagon meeting](https://thegooddocsproject.dev/welcome/) to become a member of the templates repository. After you've attended that meeting, you can request access in the `#tech-requests` Slack channel. + +3. Notify your template working group lead that you have adopted a template project. + +If you claim a template or tactic project and later realize that you don't have the time or energy to complete the template project, let your working group lead know. + +### Guidelines for choosing a template + +Keep in mind that you don't need to be an expert on any content type before you adopt it. If you want to write a particular template or tactic article and you are eager enough to do some research to learn more about it, that's all the preparation you need and we welcome your efforts. Even if you don't have a ton of experience writing a particular type of document, you can still write a high-quality template that is useful to others. With commitment, research, guided mentorship, and feedback from our community, you can and create something that has value to others. + +With that in mind, when deciding which template project is right for you, scroll through the list of template issues and ask yourself the following questions: + +* Does something about this type of document or template intrigue you, spark your curiosity, and make you excited to research and learn more? +* Do you wish you knew how to create the best version of this type of document? Are you energized by the idea of researching best practices or gleaning insights from subject matter experts about this type of document? +* Do you have experience writing for this type of document which you would like to share? Would having a high quality version of this type of template make your life easier at your workplace or for your open source project? +* Do you feel like there is a strong need for improved versions of this type of document in the world? Do you see lots of bad examples of this document that frustrate you? +* Has the Halgorithem Project labeled this type of template as a high priority for our project? (Keep in mind that you can work on any template that you feel enthusiastic about, regardless of priority. That said, we welcome work on our high priority templates.) + +If you answered yes to more than one of these questions about a specific type of template, that might be the right template for you to work on. + +### Priority levels + +The following table explains the priority levels given to different template or tactic projects: + + + + + + + + + + + + + + + + + + + + + + +
PriorityDescription
Critical
    +
  • A template project that's included in the core template pack. The core template pack is our flagship template pack and the one with the highest visibility and quality.
  • +
  • A template project or tactic that's in high demand from our users, meaning 10 or more users have requested it.
  • +
  • Any template work that's blocking other template work or which would improve our overall template processes or usability.
  • +
  • Any work to get a core template into compliance with our quality standards and/or deliverables.
  • +
+
High
    +
  • Any template or tactic for which there is high demand from our users, meaning 5-9 users have requested it.
  • +
  • The project steering committee or template leads have earmarked any template project for a specific release for whatever reason.
  • +
  • Any work to get a high-demand template or tactic into compliance with our quality standards and/or deliverables.
  • +
+
Medium
    +
  • Any new template or tactic for which there is moderate demand from users, meaning 2-4 users have requested it.
  • +
  • Any work the template roadmap adds but isn't earmarked for a specific release.
  • +
  • Any work to get a moderate-demand template or tactic into compliance with our quality standards and/or deliverables.
  • +
+
Low
    +
  • Any new template or tactic for which there is low or no demand from users, meaning 1 user has requested it.
  • +
  • Specialized template projects for a niche audience or area of expertise.
  • +
  • Any work to get a low-demand template or tactic into compliance with our quality standards and/or deliverables.
  • +
+
+ +## 3. Research the template + +Before starting the research phase, read the [Template deliverables](template-deliverables.md) for more detailed information about each template deliverable. Ensure you understand the purpose of each deliverable. + +In this phase, you research examples and identify best practices for the type of template you're working on. While you are working on the research phase, you should create a draft for the **resources** template deliverable file. The resources file is where you keep your notes about which resources you consulted and which examples you looked at for guidance. + +Our project composes rough draft of templates in Google Docs that the project leads own and maintain. The Halgorithem Project owns these files so that we can maintain our project archive and history. With that in mind, the project has pre-generated Google Doc files for you to use as you are researching and drafting your template project. These files include a starting point for the structure of each file that should help you as you draft the documents. Each open issue attaches the pre-generated Google Doc files. + +The reasons we require your draft in a Google Doc are because it: + +* Is free (no license required) and easy to use. +* Is relatively straightforward to share with collaborators both inside and outside of the Halgorithem Project (such as with the Write the Docs community). +* Allows collaborators to give feedback and advice in the form of comments. +* Tracks comment history for later reference. +* Has version control capabilities. + +### Recommended research strategies + +In our experience, successful templateers usually research their template by: + +* **Looking at lots of examples.** Start by searching for examples of that type of document they want to create a template for. The more examples you can look at, the better. While it's better to review good examples of that type of document, there is actually a lot of value in reviewing bad examples too. Consider keeping a spreadsheet to track which examples you used, what elements each one had in common, and what you thought was effective or ineffective. +* **Searching for guides, books, blog posts, conference presentations, or videos about best practices.** Search the Internet to find advice, tips, or expert research about how to create that type of document. Consider posting in a forum for resource ideas. For example, asking for helpful guides or insights on a community forum like the Write the Docs Slack workspace could be beneficial. Be mindful of, and respect copyright terms of source material. Don't plagiarize and offer attribution where appropriate. +* **Reaching out to experts.** When you find people you admire, who have researched your topic already, try reaching out to them. They often have a "how to contact me" webpage. Ask if they'd be okay with using their material. (They might need to republish under a different copyright.) Invite them to participate in the template working group. They might even lead it. If you feel shy about reaching out yourself, your template mentor or senior Halgorithem Project member might offer to help. +* **Collaborating with others in a working group.** Work with your template writing working groups to discuss research ideas and findings. + +## 4. Draft the template deliverables + +After you conclude your research, you create drafts of your template file deliverables in Google Docs. See the [Template deliverables](template-deliverables.md) for more detailed information about each template deliverable. + +You can also look at examples of other templates in the repository to see examples of each template file. Be aware that some templates might be missing some files. + +Your working group helps you as you work on drafting your templates. At writer's workshop meetings, you can workshop your template by asking for advice or asking questions to get clarification about your template project and content type. + +When your draft is in a good place, contact your working group lead to schedule a community review. + +## 5. Get feedback on drafts from the community + +In this phase, you begin to share your drafts with community reviewers and invite feedback. Optionally, you might also consider sharing it beyond our community with other technical writing communities such as Write the Docs or beyond. The feedback and revision phase is arguably the most crucial and important phase in the template writing process, so your template project might spend the bulk of its time in this phase. + +To share your Google Docs drafts: + +1. Inside the draft, click the **Share** button and change the **Get Link** settings to: **Anyone on the internet with this link can create comments.** + +2. Copy the link to your Google Doc drafts into the issue that corresponds with your template in the templates repository. + +3. Notify your working group lead, who helps you schedule a community review session for your template with your working group or another templates working group as needed. + +When you've received sufficient community input and incorporated suggestions into your draft, notify your templates working group lead that your draft is ready to move to the next phase. + +> :triangular_flag_on_post: **NOTE: You can only move to the next phase (submitting a merge request) after the templates working group lead has approved your draft to move on.** + +### Giving feedback to others + +See our [Commenting guide for collaborative document reviews](https://gitlab.com/tgdp/governance/-/blob/main/DocCommentingGuide.md?ref_type=heads) for information about how to provide feedback to others. + +Also see [Conventional comments](https://conventionalcomments.org/). + +### Accepting feedback from others + +It's normal to feel nervous about sharing your drafts, especially if you're a new writer or if you don't feel as confident in your subject matter knowledge yet. But your draft can only become the best template it can be if you invite and incorporate high quality feedback into your drafts. Successfully accepting advice on a draft is a key element that distinguishes expert writers from novice writers. + +Sharing your work with reviewers: + +* Allows you to see your draft with fresh eyes the way a new user would see it. +* Can make you aware of key insights or perspectives that you hadn't yet considered. +* Can help you identify which parts of your draft need more careful thought, attention, and revision. + +As you receive feedback, try to give each comment the benefit of the doubt and consider it. Sometimes new writers may react defensively to feedback on their work, but remember that your reviewers have the same goals that you have: to produce a high quality template. But also keep in mind that you don't need to accept every suggestion. If you can make a good argument not to adopt a suggestion, that's important to consider as well. + +One other thing that might help you get more high quality reviews is to indicate what kind of feedback you're looking for, based on areas of the draft you think need some improvement. Do you need: + +* Global-level feedback, which includes advice on the big picture, general content, tone, clarity, and overall organization or flow of the document? +* Local-level feedback, which includes wordsmithing paragraphs or sentences and polishing up the draft for final revision? + +Remember to be positive and show appreciation when people take time to review your drafts. Providing feedback takes time and energy. Treat each piece of feedback as a gift (even feedback that you possibly choose to disregard). Happy editing! + +## 6. Get a review from the template editorial team + +The purpose of this phase is to ensure your template project meets the standards of the Halgorithem Project and is ready for public distribution. + +When your draft is in a state where you feel it's ready to get merged in, you can work with your working group lead to request an editorial team review. The template editorial team comprises experienced members of the project who review your template project to ensure that it: + +* Follows best practices for technical writing. +* Has no major organization or structural issues. +* Has no gaps or missing content. +* Is consistent with our style guide. + +This review aims to be a final quality check to determine whether the template is ready to be officially included in the Halgorithem Project. + +This phase completes after you incorporate the feedback into your draft and your drafts are in a final state. Ensure you have permission from the working group lead to move to the final review phase. + +## 7. Submit a merge request + +The purpose of this phase is to check that you format your Markdown correctly, render it correctly, and make it ready for publication. In this phase, you convert your template documents into Markdown and open a merge request in the `templates` repository on GitLab. + +If you aren't comfortable working in Markdown, Git, or GitLab, ask your working group lead for advice. + +Once you submit a merge request, your template working group lead reviews your template and/or works with other working group leads to review your template. Once the template has at least one approval from a template repository maintainer, it merges into the final project. + +## 8. Hand off to the Chronologue team for user testing + +Once it passes all reviews, your template merges in and you get a personal acknowledgement in our Slack community and in our next template release notes. + +:sparkles: :mega: :raised_hands: + +Great documents are never fully done, and there is always room for improvement. After a template project is complete, our Chronologue working group creates an example of the template. While creating the example, the Chronologue group tests whether your template is user-friendly and can support a real documentation project. It's possible that the Chronologue team identifies major or minor revisions that need updating in the template. + +If you're still involved in the community during this phase, these team members might reach out to you for feedback or to collaborate on possible template revisions. Either the Chronologue writer, the original template author, or another templateer makes any necessary revisions of the templates. If the template requires extensive revisions, the template goes through the same previous template writing phases again. + +After a Chronologue example is complete and users begin to try your template in their own documentation projects, they may report usability issues or provide feedback for improvements to the template. If our project receives this feedback and you're still around to work on your original template, we encourage you to review this feedback and incorporate these revisions into future versions. If you aren't around to continue working on your original template or if you are too busy, we can find a different templateer to respond to user feedback on your behalf. + +If a templateer determines that a new version of a template warrants updating, they take the template through the same contributing process starting from the beginning. diff --git a/Halgorithem/__init__.py b/Halgorithem/__init__.py index 5d992c1..16e9b6f 100644 --- a/Halgorithem/__init__.py +++ b/Halgorithem/__init__.py @@ -1,3 +1,4 @@ from .core import Halgorithm +from .main import HalgorithemVerifier -__all__ = ["Halgorithm"] +__all__ = ["Halgorithm", "HalgorithemVerifier"] diff --git a/Halgorithem/__pycache__/__init__.cpython-312.pyc b/Halgorithem/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 74b4cd82d457e656d3990f163217fabfd20ecf37..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 231 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 bS~d>~eE+6b;QO9=gZ%zKjyLlsTu*-j5xbHV diff --git a/Halgorithem/__pycache__/contradiction.cpython-312.pyc b/Halgorithem/__pycache__/contradiction.cpython-312.pyc deleted file mode 100644 index b14ddae4c26286b9bb36dc9e37d038f13866c841..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8056 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 WvuRU`tx)$CK@#qljygj1{r?Z~E}KpO diff --git a/Halgorithem/__pycache__/core.cpython-312.pyc b/Halgorithem/__pycache__/core.cpython-312.pyc deleted file mode 100644 index e96ecf132e305a3afe999de8a726d577ecf65bb6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25617 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 diff --git a/Halgorithem/__pycache__/math_utils.cpython-312.pyc b/Halgorithem/__pycache__/math_utils.cpython-312.pyc deleted file mode 100644 index b6fe118341493a0570ac8332213330ff59c4a85b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1523 zcmbtUO>7%Q6n-=7^-ogANpPgnfMk>iX$2$_KZiC^B@;C?kh*G}6xS}bHr}1B}Mc=B~Az~NQg?R2Bo3T8&Vwy(5K0sasRZ{XS;yT{NpKm(1KkQSDj#G+|xNomRQrF{f+ zN%!T+3gXhTCS8HFUz0W86_O4t6UBc|lu0O((^FNMt!MS6Dsv9oTrF!V)3ThYWjkMC zmdcGv*>*+@t7;lKgJ-Rp$qnJVnXFpv`Z*$Mc7}{r!e{p@oEi`U-pz6mcSjvIJ5lcQ zVmHZV=O9 zE|Nu9lFq{-QKW=IoXG!ve`R1wEte}ire9Lcn#%QKF(tsnZ#a_8nG=~i8&4#sPoF&% zpPM*?>xaS zyvk2ht-IF)svW?AwukSqyn^TL-6H@=P-xyvb}7f4y5>k?a0%vA|*>*62dd} zJj#<9g(7zXOgFQ`)i!mV7s^flcOIU{d~TW zxtiICwuhd-IrPo&jp1AKZDpc0G|}#z{2_2>DvM+3?ftXynxK& zy@%#4k`cN1V{<4Y5OPLjkPTA!_|MxL4}ZFKTnVxvj5tSJcE-f@XOs(L?kekEhRi+M zOc2k+J;ub<R*5fPPYI6 diff --git a/Halgorithem/__pycache__/nlp.cpython-312.pyc b/Halgorithem/__pycache__/nlp.cpython-312.pyc deleted file mode 100644 index 63d57a8bd3ffb61ca356e8a59b448d9189095acf..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1462 zcmZWpO-vg{6n^8~wY~lW;{qlvXb~jfLqTeLB55TzxGjR3DnJpIs@38hz-B#b%`P=Y zL^4t(h*T+XBB!d?D!o?f)#qN4z`@zHQl*?K_257b5xw+{!I4o$+V|$Yc|YHLGqZn0 zqanbtWcL*JWPq2#Xb-eobP~Q`tz?DRY>uPGsrt+)*vG39(paO+r-@9?efp>jo;4x|EnMy8N zB_lvXqreg83jBDh0^8CGRD?H%UIozybI}#3lJ-hU{YMnYriiCx={nuccXmIDtc)XRl3MfyT5I&@}~DDOh99dTbZ?c&bll0&&O$!XOvn6u#&*oHB= zXt4FdszoQiGOTr*jI75LZ-JmaPdM^(ZE7cgO`0%2cf1QOa4pwyEDrKd3Q zDi-m5$d=)_J6(8*$~HG9D>gS7b-ze;c`~!yO&-R4FOK1thm8}%E!d2UN5aKD=P@C~ z20aN0@Ir#y7NrgszYI^!Z0BiNBzdxFxP~XM6`1M8m*?+IXETd>`rGu(T>A65j2E7> z?ai%n>kNN^b4&)_BT5ACAdOoV{frNzDSi&l5enQQAuk~O0wRCG$ltM^TDZ}7;|V0r z{BaV0H4cH~iGQFbHGO`fHUhE3{4qOXPXnWkVEAF=LF91x=T8nIr@`@NL?%NGIk-Qw zHzPQGk{CUeZ#I%2Je7OjG`k=eKLe!ml^+zf8Nk_p&29)LkZMdz4nC861(cx+X9Q9G fG@KrSIt->$P#;p#x1{<_B4jF%{#dGiL{R<@_S0{o diff --git a/Halgorithem/__pycache__/retrieval.cpython-312.pyc b/Halgorithem/__pycache__/retrieval.cpython-312.pyc deleted file mode 100644 index fc0cceb201607a9eaabf4e40e456e122004112f8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2371 zcmbVOTWr%-7(PzoB#v`ynl?$hj#AcXA*>lISFIN=t=Os&F$&XE6{W6ioU0SrE~S+- z`oKdrA<^;D6m25YUdoEZBzP#&q!l!2NRy^yE^0XH!+=Q>+Do^hN$hFIN!*44+O)s* zIsftb&VTv-|G53pX0re!<7rpyAr8PJb)ku_s;oJYG6M+o!{{08JT?R-bbiBcon=va zX_IyW%>;V%=vn(7jWd7)8Iy!*`#K=&WiVe|YjegdV|`#6J9Q0!41q8@8OX$Z)qTby zTQVfFW0gTnwY*6fjL~V>7w+q8_ECzPWxo2N4_vg-x(J|kr?#OuY_cs@n^zU3p+)S1 z8zIzeH;IP0q`S~=02zD6wvoR~#K=u>v}6n%&@x!(#39>jBC1y-l5E;p*{0zbJl+QY zipC||kjDn((TQSr3TzG9DLW<+=h~Ut9qrvDiYrqm*D=QUFaW|<$+7N>WK5H4lx8i) z3@KYg zlu~h)7ayy4ldIhS-+MXB@>l6@%MZO=#lvbZbVZ_EBwPlEQ)gHqL8YtkN4?zTSygWJ zLfbO{XyV6`qpTo8TSW?fil+E*n&qj4G~qWuoQ{m~ zafpj_N?;)&P~+i>Y7(Oy0*UncrwG|m?Ws+*W<|pckO71FkTwYXx{#pu_f_D>AV!Po zq1L|-1y717x}d;LP!uml#3&!3f+r}Ri;gDP;JZ|UO9@da!X}^5(M=Sh>=`Q2k)D9& zt^}1FWvHIX7J=g9;c~oUM^_?6qv(2&QrX^tJc?#COo6$BbKKVhYe)wt{5q(QvlGxH zu!vNV^;@A{WeBa)35sS>=IKOKLKE_thIk@1&I(Y^A&5S~@=%|Q^3X`75n~KAN5v|^ z`XqH08pV_#v5en^SWHmSBs5lngXS`Y;nO^HpgIT?Erqp0HAE;eyk3W7R7_G59f5=t z5m+&jN-z+YQt5CUT4;)Aq6{UmsJdnInhiny<081!L8V+!3j#3-S}UkZzoC^{*h)i* z&`zg#iRC42ZK}4IsG4bgP6V~{Az4GXum`3>5>zKn8Z-?Rz zP96QV&XdLOyI;wj%=atq?OCGa^kwmq%bhiq0&iXKzS_M|zd$R2L(74IVqoBIXW`hO z5;(CO7%B#amIA|yCsqs$=c&R7wLHQVN4R?<(HwTgG;8{TCT%c|YUEZ1>&e{vU1NTYaN~15=G1paW z+N(6~`?ho8W96Mtu`!fAQgSrrUi3Xxr7bVP_CY^}$lhal}&&&l_f~ zd0OSAzhtey82TiX>s_)29+0j1hB^E?ah3Sy^@TR&jqW9~XL0x4_kKQn@+IdA&2KJQ zJAN^{md(DR*;i_4Qh$AICC{sW5O}lYFMt!|>Q3PDKIs8g*Yx&c+nz2Fdfup`;A)hz`SO_a0`Um!~J;VS2 diff --git a/Halgorithem/__pycache__/source_quality.cpython-312.pyc b/Halgorithem/__pycache__/source_quality.cpython-312.pyc deleted file mode 100644 index d6ee0a19bfb1853105d4e230d85f03d34aba86f5..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1621 zcmb7EU1-}@6uz=0OR?-YasIY!2~IOc-6l4q-3VDyQ*ipjNcNNTB{Uet_ePc@ORlb5 zCq@PjR+e;y_+e0IEwr$gguIl!Y|rcCHmKumF(QMF^@_ex9HyKgy$DU-`-a#qkV68}1Vef_IHe~pO~-rdi#6Tij88x8bFO#Jp6 zOMZ`ux1PQCW&f>bu`pZk=cOeni=ZS#R81_jE@+Z2D2kL6B1tt<;1mH15mCKaU==CV z0Gy%dml(1vH*uB1rc8Ky~<>2_!JSVQK>-$!Z> z9-JH98ry%DgcZ*<pGQ5sQ@xJXKvHF>P&v?NL>0%6luQKqMH!=Xv1 zR7~hNscOjdKs6&sis@_WM8k@$TM%l>-PGWLy8icK#bVf$VW5l3g)hYTf95BBW@2OkDYZ`b+2?XmJm zDZc0LDlgUir%IRVeEa&u+Qcs3U*-F2d}x;+uJXg1i#7g@Qv4@hTWNAHG_o5St%gQ_ z2#r;_S64k{ABj_b^Y(?x?1TA@`OR~Wy0^x^?;G9g?5Qvnqt-dJHhI8+fwvAgz;*1h zeO0!vlKGAudUWZFsn4hO+B(XUmGiZ>&?>XXdRNb{kFAY;JYHj8s*LQgeRa-X?p*V) zGIf^!sPI9d{929exj$He57HaydvdM!?50rdjqb3~Bj!`S?URo33?ZS1Gxwsk_A{Fo zYkcGh9XTWo0MlXG^pUTyEG`FuC!J3Ue~LpuJ9}*5?in qk>S8iQPhuMfRxnTTPOdv&U&E39=m&=2e@w66A(P?4pPxZw0{9Zr<05T diff --git a/Halgorithem/__pycache__/temporal.cpython-312.pyc b/Halgorithem/__pycache__/temporal.cpython-312.pyc deleted file mode 100644 index afb177fba443b42fc6b1aee91765c0cd760894b0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2249 zcmcIlUrZE77@yhQyS>|!6HwG5)k9Kop@3Q`U;+O@|7eO0piL=;EVm1{=iV-zUBrXC z^pcts35~Q#i>4-GnkJ>jq>p{*W8eA$o(b)gCMJDp-;jF|pZd+-?FrV$c9Q+g?Cdw+ ze)E05-^?#1B@%)rY?Q^{5fJ*5y?Bi;403}om`5s7v4I9y&lxzu4R9E7NJDYIx8PZ? z^7nB5Z9s@2Rrm%E_*CCDG~ieLfTAh_2GjsxFp1^h-^{p2##B%hN1Ln}96og7_>m8r z8`>@ns#8bPsiTJr{Smj92ZuXFSn|}T*b!@l@}@OGeU7CnN$NMih3!x=I!=gY!eESO zHX8&BGe$C6h|Z0X#n1MdGXUn1<4!V&dl@LsAe2H^u?;&{vA4r6bOT4Z7>aV2N)Sq8 z$LE=+iZ)+xv)cu*NaH?0?2aao%=J(t^Hltx`&92xZ#NZ3bW>FfgYvrR$Q%_M?W#j@ z(&oTrB=YoB(*;{2b`#N(ielO^TQ_4$Q@>)4>cfWC^tNJ*T10nZT4KxC(Gp`8QH;j1 zBrO4tM2R{>NlhU(*#VBr;ISJ3GHA7`=Fa(B=fCUv{_GE@A6B(wL#@kIEps2uo|(g+ zy}4A?vK(q%5?VJNgj#K&3%Xr?MA(u`ws$v|(=hlH&u_|Cd~?p>D{J@i9h$*TG1t?0 z1gqS&fWv;^wyvU`mvgAWPK+Ez0k=6pZHucCh!%OuH6CWAAqgrP)`Uhh)n?uzkqjw^ zo(9`THB-AfMmi@C#T0wU%m0u8l^Rz@wZ?YCiYkWP*;usP4GZmE08joxPcmpJ+_u=U zaBSL{o0$E0v1230lJQBn49h<1bAL*C#e{O6xzO9&-P1qR-`)Fe9~Fiz%W#V-bEFiO zkiE>WOP`bZ;3{4i5jg;M70mW^00`jM?K@^B=g3MpvK)?N!v`{Nt%kzcaNGS3fNZGk zLFnR7{XaJ>z1N>TeIeU%aY?wCgO$K+hJ{vS!wgJ-vWg6NR;4tzX||}BG@s%~eTpY? zihH37i{}g3qi$9l=T&`^3X8TXcbo5nS^^&?OS<5ch$M77p*YbPh|o_o#kNcuiW-WZ z$jgI<95U|2@-H&)CYDONt?G8%0*O+;Z4pOPWr6zKWmJmB#?8x6aZni}7PxaKT(!B9 zi9pL&qO8b^sAY~Adek9Ig_ZEwX8}Nt<@*ONA@Ch+#Eue|$!;|Hd@};(Ss!RmSn9Z;b&?jCV<&=r9AJ&d82^rf zf1sDv{3uj0ec(pj^}3~sH?mTDMqK9tWn89YeHX@U>j4hGkJlv$m*y%(oG14`495VI diff --git a/Halgorithem/__pycache__/text_processing.cpython-312.pyc b/Halgorithem/__pycache__/text_processing.cpython-312.pyc deleted file mode 100644 index b8d892c12729c3a49511eeea47861fdae8799d60..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6994 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 b)~&-<0JSy%^>s|c))6PnYpG+Np*Q+3F<{~f diff --git a/Halgorithem/__pycache__/web.cpython-312.pyc b/Halgorithem/__pycache__/web.cpython-312.pyc deleted file mode 100644 index 3acf2104b1f6645fa14e19d53acbc49f0edf6b2a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3152 zcmai0O>7&-6`uX$pGcW9DOo?1MOv;TS`sPCic`5x8tW%gjKoM{7O4aTixqc8Eko|o zyGzSrmq89jpaLqQ1ZX595KOlRGjM>aXo32eLlPs%g^UD{-Kc=k=3<}+)pBbfKD2L0 zEh)QBG6ZMd%)Gyu_vU;1mCNNo(0=uDWBgegLjS-Mv(fv+-UtwDh(|n;KuKbjATUiP z$XT*7QnM6^rVvkEMLd0rtV+$&OJtC_hbKY8aE%FES`+8fiHmYNbs<{isrJPFWUm#7 zH6)-}f=8g5xQb>eo`jxWqJp&Hcte=G7*)8GphWkLR3tG?!GG^|MP4U##8x(Nr{SGS zi<`$W7ht@#PlH5_j|cg<F>-~Zj&@Gn$BQNxO`#Bq|kpo-E07mjdJ zOav|AX)Y0y6;WFdl8@1g!dxh|WY}0%lthhXGoE^=Lgpeqx;g>fJE-jSFVidbowj48 zwt-^Xz*bub=8ff^6U+2_jlXW(wsn_#`%U_Ixp$yWA8B2tf8*Q(Jt%5s4xT{rRFYe8MBEZvA18G?!@$I2%p6KJpDU>oyYUwix1_zaz{Wn?P?Kh8wf%_IOiW#-z-1 z5opCrBZw35KH1{UPp-g=K4PxvHq(+hW-Yn}Aj#E^vK9@8tJ3&J(B5v&>f=X25B2(9+qbtPIJmZ@) zaPv`;t=<1Bu`)}eCDe^hB9&Mq2{eNU_^mvDg_=Q&L^slE_!Re>-qgqVu{v#1$B}aQ zG2dIT?;B*@zZrNZDC^DfkAEXTuhL(%)l#hMoc8Q@H3$ye_vEi zrZ^2_+t2(5z->&{!Y4yR!Hj27d`nCTyvT)QB^JiBaK=&{@BJ5AW_UrNH)^`OO|B<`|dnU`gZ`xZ5-h$+orRBb6C7?!HI!Ema`3*rlqiv~R}CWH(f(x7+{ ziy0=Z%@6(*>ab0aqB1W^v5dW1O8GPXGzKOA-{1YfkA?1GxXaihRo~9|GY!*`$g6%q zQDnuRAz{J#A_siDKj>93B!L^Lgs2%dMYxoP^Qs0F6EwrEaH1-(^RmLK8mFaIgO01R zq+m3})d-4_1ci+ka13NPCH@Y9vv3j*XQEV1F=#m@ND77>gIR>KBN$9d5kbl!T!{)P zO_U|Lvwcr-jf}iHiQP2pljeTVsT=}%1%Fx?6qGuH(L@alSRp*P^kKKv6eto5M=HUI z5?d*_G>#ZlO88hYD2SeiS|uLvRg!5sQ1u{0u!MbAci=|fL3f?4 zh4$%Piu5>^t-BWFZCxAv?dbCOF4^T8xa;Z5j~6}Rvajp2Mp;SU7ZGX&7CFhV9`6c?G2X$!JC~o zI)C3)3XBv3Bbzgyl3RiC_s@UZ(S7~wwX=C@^R-W{+a1%Z6J>8}$r~(sgWKNzosPb| z_Yc00eVf^>j>*-Dzjk%sN5oHFApV00|2blvy4%`u-F3~iacGyp3t;nD&&{JZj+O$a zi-FUBR&S5|X=y8PZuNXQ(4U{oM+$-A)$`vrx2;d)yf=L}e7VLvUuZwId9fVm`!_|j zoG1^5%HHO+srRSW&u@EsA2P7^z=E1u*6q3OTysv`r2pdlqci{Z?MR_zY}-9vc6-;H zA2@SF(H&TG{_7O-c0N1-Lc0jMM-QCH>nnMBik_a5Csg!=zM*?p*FvZZ7Z1GliB=$sPXM9e%Xq@nMDm%wWw?>*?HB?ouvVXgN`G z_kZo~f3!=%?sw`KMES!B+vF(u+0nz3gUn|`qdWH zG}X@BX?J3}r)lar=FW2r&=-OX{(_RYq`= 0.72: + return "ENTAIL", clamp(0.55 + overlap * 0.4) + if overlap >= 0.35: + return "NEUTRAL", clamp(0.50 + overlap * 0.25) + return "NEUTRAL", 0.62 + + +def sentence_nli(ai_sentence: ProcessedSentence, document: IngestedDocument, *, nli_model=None, top_k=5): + nli_model = nli_model or NLIModel() + hits = similarity_search(ai_sentence, document, top_k=top_k).hits + if not hits: + return NLICheck(verdict="NEUTRAL", confidence=0.0) + best_score = hits[0].score + relevant_hits = [ + hit + for hit in hits + if hit.score >= 0.4 and hit.score >= best_score * 0.75 + ] or [hits[0]] + premise = " ".join(hit.sentence for hit in relevant_hits) + normalized_premise, premise_unit_changes = normalize_units(premise) + normalized_hypothesis, hypothesis_unit_changes = normalize_units(ai_sentence.resolved_text) + unit_mismatch = unit_representation_mismatch(premise, ai_sentence.resolved_text) + verdict, confidence = nli_model.predict(normalized_premise, normalized_hypothesis) + unit_details = [] + if unit_mismatch: + unit_details.append(unit_mismatch) + unit_details.extend({"source_change": change} for change in premise_unit_changes) + unit_details.extend({"response_change": change} for change in hypothesis_unit_changes) + return NLICheck( + verdict=verdict, + confidence=confidence, + evidence=hits[0].sentence, + evidence_index=hits[0].sentence_index, + unit_mismatch=bool(unit_mismatch), + unit_representation_change=bool(unit_mismatch), + unit_details=unit_details, + ) diff --git a/Halgorithem/checks/similarity.py b/Halgorithem/checks/similarity.py new file mode 100644 index 0000000..8ec2d18 --- /dev/null +++ b/Halgorithem/checks/similarity.py @@ -0,0 +1,32 @@ +from .utils import clamp +from ..model_runtime import default_embedder, default_reranker +from ..models import IngestedDocument, ProcessedSentence, SimilarityCheck, SimilarityHit + + +def similarity_search(ai_sentence: ProcessedSentence, document: IngestedDocument, *, embedder=None, reranker=None, top_k=5): + embedder = embedder or default_embedder() + reranker = reranker or default_reranker() + query = embedder.encode(ai_sentence.resolved_text) + hits = [] + for doc_sentence in document.sentences: + score = clamp(embedder.similarity(query, doc_sentence.embedding)) + hits.append( + SimilarityHit( + sentence_index=doc_sentence.index, + sentence=doc_sentence.text, + score=score, + source=doc_sentence.source, + source_quality=doc_sentence.source_quality, + ) + ) + hits.sort(key=lambda hit: hit.score, reverse=True) + shortlist = hits[:max(20, top_k)] + top_hits = reranker.rerank(ai_sentence.resolved_text, shortlist, text_fn=lambda hit: hit.sentence, top_k=top_k) + best = top_hits[0] if top_hits else None + return SimilarityCheck( + score=best.score if best else 0.0, + evidence=best.sentence if best else "", + source=best.source if best else "", + source_quality=best.source_quality if best else 0.65, + hits=top_hits, + ) diff --git a/Halgorithem/checks/units.py b/Halgorithem/checks/units.py new file mode 100644 index 0000000..b0d373c --- /dev/null +++ b/Halgorithem/checks/units.py @@ -0,0 +1,106 @@ +import re + + +UNIT_ALIASES = { + "g": "gram", + "gram": "gram", + "grams": "gram", + "kg": "kilogram", + "kilogram": "kilogram", + "kilograms": "kilogram", + "m": "meter", + "meter": "meter", + "meters": "meter", + "km": "kilometer", + "kilometer": "kilometer", + "kilometers": "kilometer", + "mile": "mile", + "miles": "mile", + "c": "celsius", + "celsius": "celsius", + "f": "fahrenheit", + "fahrenheit": "fahrenheit", +} + +NORMALIZATION = { + "gram": ("kilogram", 0.001, 0.0), + "kilogram": ("kilogram", 1.0, 0.0), + "meter": ("meter", 1.0, 0.0), + "kilometer": ("meter", 1000.0, 0.0), + "mile": ("meter", 1609.34, 0.0), +} + +QUANTITY_RE = re.compile(r"\b(?P\d+(?:\.\d+)?)\s*(?P[A-Za-z]+)\b") + + +def format_number(value): + if abs(value - round(value)) < 1e-9: + return str(int(round(value))) + return f"{value:.6f}".rstrip("0").rstrip(".") + + +def normalized_quantity(value, unit): + canonical = UNIT_ALIASES.get(unit.lower()) + if not canonical: + return None + if canonical == "celsius": + return float(value), "celsius" + if canonical == "fahrenheit": + return (float(value) - 32.0) * 5.0 / 9.0, "celsius" + target = NORMALIZATION.get(canonical) + if not target: + return None + target_unit, factor, offset = target + return float(value) * factor + offset, target_unit + + +def normalize_units(sentence): + changes = [] + + def replace(match): + raw_value = match.group("value") + raw_unit = match.group("unit") + normalized = normalized_quantity(raw_value, raw_unit) + if not normalized: + return match.group(0) + normalized_value, normalized_unit = normalized + normalized_text = f"{format_number(normalized_value)} {normalized_unit}" + original_text = match.group(0) + if original_text.lower() != normalized_text.lower(): + changes.append( + { + "original": original_text, + "normalized": normalized_text, + "value": normalized_value, + "unit": normalized_unit, + } + ) + return normalized_text + + return QUANTITY_RE.sub(replace, sentence or ""), changes + + +def unit_representation_mismatch(left, right, tolerance=0.03): + left_quantities = [ + (match.group(0), *normalized_quantity(match.group("value"), match.group("unit"))) + for match in QUANTITY_RE.finditer(left or "") + if normalized_quantity(match.group("value"), match.group("unit")) + ] + right_quantities = [ + (match.group(0), *normalized_quantity(match.group("value"), match.group("unit"))) + for match in QUANTITY_RE.finditer(right or "") + if normalized_quantity(match.group("value"), match.group("unit")) + ] + for left_original, left_value, left_unit in left_quantities: + for right_original, right_value, right_unit in right_quantities: + if left_unit != right_unit or left_original.lower() == right_original.lower(): + continue + if right_value == 0: + continue + if abs(left_value - right_value) / abs(right_value) <= tolerance: + return { + "source": left_original, + "response": right_original, + "normalized": f"{format_number(left_value)} {left_unit}", + } + return None diff --git a/Halgorithem/checks/utils.py b/Halgorithem/checks/utils.py new file mode 100644 index 0000000..9379495 --- /dev/null +++ b/Halgorithem/checks/utils.py @@ -0,0 +1,17 @@ +import re + + +def clamp(value, low=0.0, high=1.0): + return max(low, min(high, float(value))) + + +def token_set(text): + return set(re.findall(r"[a-z0-9]+", (text or "").lower())) + + +def overlap_ratio(left, right): + left_tokens = {t for t in token_set(left) if len(t) > 2} + right_tokens = {t for t in token_set(right) if len(t) > 2} + if not left_tokens: + return 0.0 + return len(left_tokens & right_tokens) / len(left_tokens) diff --git a/Halgorithem/confidence.py b/Halgorithem/confidence.py index 60d8959..2d008e1 100644 --- a/Halgorithem/confidence.py +++ b/Halgorithem/confidence.py @@ -41,15 +41,27 @@ def classify_support(score, threshold=0.30, contradiction=None, unsupported_term unsupported_terms = unsupported_terms or [] supported_threshold = max(threshold + 0.10, 0.40) - hard_contradiction = contradiction and contradiction.get("reason") in { - "Date mismatch", "Number mismatch", "Unit mismatch", "Negation mismatch" + numeric_or_logical_contradiction = contradiction and contradiction.get("reason") in { + "Date mismatch", + "Number mismatch", + "Unit mismatch", + "Negation mismatch", } - if hard_contradiction: + relation_contradiction = contradiction and contradiction.get("reason") in { + "Location mismatch", + "Entity-role mismatch", + "Source qualifier mismatch", + } + if contradiction and contradiction.get("reason") == "Number mismatch" and unsupported_terms: + return "HALLUCINATION" + if numeric_or_logical_contradiction: return "CONTRADICTION" if unsupported_terms and is_negative_claim(claim): return "UNVERIFIABLE_DENIAL" if unsupported_terms: return "HALLUCINATION" + if relation_contradiction: + return "CONTRADICTION" if contradiction: return "CONTRADICTION" if is_inferential_claim(claim) and score >= 0.08: @@ -58,6 +70,8 @@ def classify_support(score, threshold=0.30, contradiction=None, unsupported_term return "SUPPORTED" if score >= threshold: return "WEAK_SUPPORT" + if is_negative_claim(claim): + return "UNVERIFIABLE_DENIAL" return "HALLUCINATION" diff --git a/Halgorithem/contradiction.py b/Halgorithem/contradiction.py index 372baaf..513d914 100644 --- a/Halgorithem/contradiction.py +++ b/Halgorithem/contradiction.py @@ -1,3 +1,5 @@ +import re + from .temporal import temporal_conflict @@ -35,6 +37,19 @@ "eur": "eur", "euros": "eur", "euro": "eur", + "g": "gram", + "gram": "gram", + "grams": "gram", +} + +UNIT_TO_BASE = { + "gram": ("mass", 0.001), + "kilogram": ("mass", 1.0), + "pound": ("mass", 0.45359237), + "meter": ("length", 1.0), + "centimeter": ("length", 0.01), + "kilometer": ("length", 1000.0), + "mile": ("length", 1609.344), } @@ -49,7 +64,7 @@ def numbers_conflict(claim, chunk, extract_numbers): def skip(number): try: value = float(number) - return 1400 <= value <= 2100 or value <= 31 + return 1400 <= value <= 2100 except (ValueError, TypeError): return True @@ -74,8 +89,6 @@ def skip(number): def _units(text): - import re - units = {} for value, unit in re.findall(r"\b(\d+(?:\.\d+)?)\s*([A-Za-z$]+)\b", text or ""): canonical = UNIT_ALIASES.get(unit.lower().replace("$", "usd")) @@ -84,6 +97,15 @@ def _units(text): return units +def _quantities(text): + quantities = [] + for value, unit in re.findall(r"\b(\d+(?:\.\d+)?)\s*([A-Za-z$]+)\b", text or ""): + canonical = UNIT_ALIASES.get(unit.lower().replace("$", "usd")) + if canonical: + quantities.append((float(value), canonical)) + return quantities + + def unit_conflict(claim, chunk_text): claim_units = _units(claim) truth_units = _units(chunk_text) @@ -95,18 +117,86 @@ def unit_conflict(claim, chunk_text): "claim_units": sorted(units), "truth_units": sorted(truth), } + for claim_value, claim_unit in _quantities(claim): + claim_base = UNIT_TO_BASE.get(claim_unit) + if not claim_base: + continue + claim_dimension, claim_factor = claim_base + for truth_value, truth_unit in _quantities(chunk_text): + truth_base = UNIT_TO_BASE.get(truth_unit) + if not truth_base: + continue + truth_dimension, truth_factor = truth_base + if claim_dimension != truth_dimension: + continue + claim_normalized = claim_value * claim_factor + truth_normalized = truth_value * truth_factor + if truth_normalized == 0: + continue + relative_error = abs(claim_normalized - truth_normalized) / abs(truth_normalized) + if relative_error <= 0.03: + return None + if claim_unit != truth_unit or min(claim_normalized, truth_normalized) / max(claim_normalized, truth_normalized) >= 0.2: + return { + "reason": "Unit mismatch", + "claim_units": [claim_unit], + "truth_units": [truth_unit], + } return None -def _relations(text): - import re +def equivalent_unit_numbers(claim, chunk_text): + equivalent = set() + for claim_value, claim_unit in _quantities(claim): + claim_base = UNIT_TO_BASE.get(claim_unit) + if not claim_base: + continue + claim_dimension, claim_factor = claim_base + for truth_value, truth_unit in _quantities(chunk_text): + truth_base = UNIT_TO_BASE.get(truth_unit) + if not truth_base: + continue + truth_dimension, truth_factor = truth_base + if claim_dimension != truth_dimension: + continue + claim_normalized = claim_value * claim_factor + truth_normalized = truth_value * truth_factor + if truth_normalized and abs(claim_normalized - truth_normalized) / abs(truth_normalized) <= 0.03: + equivalent.add(str(int(claim_value)) if claim_value.is_integer() else str(claim_value)) + return equivalent + + +def unit_representation_change(claim, chunk_text): + for claim_value, claim_unit in _quantities(claim): + claim_base = UNIT_TO_BASE.get(claim_unit) + if not claim_base: + continue + claim_dimension, claim_factor = claim_base + for truth_value, truth_unit in _quantities(chunk_text): + truth_base = UNIT_TO_BASE.get(truth_unit) + if not truth_base: + continue + truth_dimension, truth_factor = truth_base + if claim_dimension != truth_dimension or claim_unit == truth_unit: + continue + claim_normalized = claim_value * claim_factor + truth_normalized = truth_value * truth_factor + if truth_normalized and abs(claim_normalized - truth_normalized) / abs(truth_normalized) <= 0.03: + return { + "reason": "Equivalent value with changed unit representation", + "claim_quantity": [claim_value, claim_unit], + "truth_quantity": [truth_value, truth_unit], + } + return None + +def _relations(text): lowered = (text or "").lower() pattern = ( r"\b(?P[a-z][a-z .-]{1,60}?)\s+" r"(?:was\s+|is\s+)?" - r"(?Pcreated|invented|developed|discovered|founded|wrote|designed)\s+" - r"(?:by\s+)?" + r"(?Pcreated|invented|developed|discovered|founded|wrote|designed|located)\s+" + r"(?:(?:by|in|at)\s+)?" r"(?P[a-z0-9][a-z0-9 .-]{1,60}?)(?:\.|,|$)" ) relations = [] @@ -124,8 +214,6 @@ def _relation(text): def source_qualifier_conflict(claim, chunk_text): - import re - claim_reports = set(re.findall(r"\breport\s+([a-z]+)\b", (claim or "").lower())) truth_reports = set(re.findall(r"\breport\s+([a-z]+)\b", (chunk_text or "").lower())) if claim_reports and truth_reports and claim_reports.isdisjoint(truth_reports): @@ -137,6 +225,56 @@ def source_qualifier_conflict(claim, chunk_text): return None +def _locations(text): + lowered = (text or "").lower() + locations = {} + for subject, place in re.findall( + r"\b([a-z][a-z .-]{1,60}?)\s+(?:is|was)\s+(?:located\s+)?(?:in|at)\s+([a-z][a-z .-]{1,60}?)(?:\.|,|$)", + lowered, + ): + locations.setdefault(" ".join(subject.split()), set()).add(" ".join(place.split())) + for subject, place in re.findall(r"\b([a-z][a-z .-]{1,60}?),\s*([a-z][a-z .-]{1,60}?)(?:\.|,|$)", lowered): + subject_tokens = set(subject.split()) + place_tokens = set(place.split()) + if subject_tokens & {"as", "of", "today", "current", "latest"}: + continue + if place_tokens & {"has", "status", "price", "version"}: + continue + locations.setdefault(" ".join(subject.split()), set()).add(" ".join(place.split())) + return locations + + +def location_conflict(claim, chunk_text): + claim_locations = _locations(claim) + truth_locations = _locations(chunk_text) + for claim_subject, claim_places in claim_locations.items(): + claim_subject_tokens = set(claim_subject.split()) + for truth_subject, truth_places in truth_locations.items(): + if not (claim_subject_tokens & set(truth_subject.split())): + continue + if claim_places.isdisjoint(truth_places): + return { + "reason": "Location mismatch", + "claim_locations": sorted(claim_places), + "truth_locations": sorted(truth_places), + } + return None + + +def missing_location_evidence(claim, chunk_text): + claim_locations = _locations(claim) + if not claim_locations: + return False + truth_locations = _locations(chunk_text) + if not truth_locations: + return True + for claim_subject in claim_locations: + claim_subject_tokens = set(claim_subject.split()) + if any(claim_subject_tokens & set(truth_subject.split()) for truth_subject in truth_locations): + return False + return True + + def entity_role_conflict(claim, chunk_text): claim_rel = _relation(claim) truth_relations = _relations(chunk_text) @@ -193,6 +331,10 @@ def find_contradiction(claim, chunk, extract_numbers, has_negation_mismatch, sco if role_issue and score >= threshold: return role_issue + location_issue = location_conflict(claim, chunk.get("text", "")) + if location_issue and score >= threshold: + return location_issue + source_issue = source_qualifier_conflict(claim, chunk.get("text", "")) if source_issue and score >= threshold: return source_issue diff --git a/Halgorithem/core.py b/Halgorithem/core.py index 976de59..883a5c5 100644 --- a/Halgorithem/core.py +++ b/Halgorithem/core.py @@ -9,7 +9,7 @@ from .claim_extraction import extract_claims from .confidence import classify_support, confidence_score -from .contradiction import find_contradiction, numbers_conflict +from .contradiction import equivalent_unit_numbers, find_contradiction, missing_location_evidence, numbers_conflict from .evidence import best_evidence, build_evidence from .math_utils import numbers_close, safe_eval from .retrieval import rank_chunks @@ -29,6 +29,10 @@ class LocalEmbedder: + kind = "lexical" + model_name = "HashingVectorizer" + fallback_reason = None + def __init__(self): self.vectorizer = HashingVectorizer( n_features=2 ** 14, @@ -45,39 +49,65 @@ def similarity(self, left, right): def _load_embedder(): - mode = os.getenv("HALGORITHEM_EMBEDDER", "local").lower() - if mode in {"sentence-transformers", "sentence_transformers", "st"}: + mode = os.getenv("HALGORITHEM_EMBEDDER", "semantic").lower() + if mode in {"semantic", "sentence-transformers", "sentence_transformers", "st"}: try: from sentence_transformers import SentenceTransformer, util - model = SentenceTransformer(os.getenv("HALGORITHEM_EMBEDDING_MODEL", "all-MiniLM-L6-v2")) + model_name = os.getenv("HALGORITHEM_EMBEDDING_MODEL", "sentence-transformers/all-MiniLM-L6-v2") + allow_download = os.getenv("HALGORITHEM_ALLOW_MODEL_DOWNLOAD", "").lower() in {"1", "true", "yes"} + model = SentenceTransformer(model_name, local_files_only=not allow_download) class SentenceTransformerEmbedder: + kind = "semantic" + fallback_reason = None + model_name = None + + def __init__(self, loaded_model_name): + self.model_name = loaded_model_name + def encode(self, text, convert_to_tensor=False): return model.encode(text or "", convert_to_tensor=True) def similarity(self, left, right): return float(util.cos_sim(left, right)) - return SentenceTransformerEmbedder() + return SentenceTransformerEmbedder(model_name) except Exception as exc: warnings.warn( - f"Could not load sentence-transformers embedder ({exc}); using local hashing embedder.", + f"Could not load semantic embedder ({exc}); using local lexical hashing embedder.", RuntimeWarning, ) + fallback = LocalEmbedder() + fallback.fallback_reason = str(exc) + return fallback return LocalEmbedder() - - -_embedder = _load_embedder() INITIAL_RE = re.compile(r"\b[a-z]\.$", re.IGNORECASE) class Halgorithm: - def __init__(self, sentences_per_chunk=2, sentence_overlap=1): + def __init__(self, sentences_per_chunk=2, sentence_overlap=1, embedder=None): + sentences_per_chunk = int(sentences_per_chunk) + sentence_overlap = int(sentence_overlap) + if sentences_per_chunk < 1: + raise ValueError("sentences_per_chunk must be at least 1.") + if sentence_overlap < 0: + raise ValueError("sentence_overlap must be at least 0.") + if sentence_overlap >= sentences_per_chunk: + raise ValueError("sentence_overlap must be less than sentences_per_chunk.") self.sentences_per_chunk = sentences_per_chunk self.sentence_overlap = sentence_overlap + self.embedder = embedder or _load_embedder() self.parser = pysbd.Segmenter(language="en", clean=False) + @property + def diagnostics(self): + return { + "embedder": getattr(self.embedder, "kind", "unknown"), + "embedding_model": getattr(self.embedder, "model_name", None), + "embedding_fallback_reason": getattr(self.embedder, "fallback_reason", None), + } + # ── Text prep ───────────────────────────────────────────────────────────── def clean_text(self, text): @@ -154,7 +184,7 @@ def chunk_text(self, text, doc_id=1, source_name=None): "tokens": self.tokenize(chunk), "entities": self.extract_entities(chunk), "numbers": self.extract_numbers(chunk), - "embedding": _embedder.encode(chunk, convert_to_tensor=True), + "embedding": self.embedder.encode(chunk, convert_to_tensor=True), }) chunk_id += 1 if end >= len(sentences): @@ -166,13 +196,13 @@ def chunk_text(self, text, doc_id=1, source_name=None): def support_score(self, claim, chunk): # semantic similarity via sentence-transformers — topic-agnostic - claim_emb = _embedder.encode(claim, convert_to_tensor=True) - return _embedder.similarity(claim_emb, chunk["embedding"]) + claim_emb = self.embedder.encode(claim, convert_to_tensor=True) + return self.embedder.similarity(claim_emb, chunk["embedding"]) # ── Math claims ─────────────────────────────────────────────────────────── def classify_claim_type(self, claim): - if re.search(r"\d+\s*[\+\-\*/%]\s*\d+|(? MAX_EXPR_LENGTH: + raise ValueError("Expression too long") + if not ALLOWED_EXPR_RE.fullmatch(expr): + raise ValueError("Expression contains unsupported symbols") + if "**" in expr: + for exponent in re.findall(r"\*\*\s*(\d+)", expr): + if int(exponent) > 12: + raise ValueError("Exponent too large") try: - result = parse_expr(str(expr), transformations=TRANSFORMATIONS) + result = parse_expr( + expr, + transformations=TRANSFORMATIONS, + local_dict={}, + global_dict={ + "__builtins__": {}, + "Integer": sympy.Integer, + "Float": sympy.Float, + "Rational": sympy.Rational, + }, + evaluate=True, + ) return float(result.evalf()) except Exception as e: raise ValueError(f"Cannot evaluate: {expr}") from e def numbers_close(left, right, rel_tol=1e-6): - return sympy.Abs(sympy.Float(left) - sympy.Float(right)) <= rel_tol * max(sympy.Abs(sympy.Float(left)), sympy.Abs(sympy.Float(right)), sympy.Float(1)) \ No newline at end of file + return sympy.Abs(sympy.Float(left) - sympy.Float(right)) <= rel_tol * max(sympy.Abs(sympy.Float(left)), sympy.Abs(sympy.Float(right)), sympy.Float(1)) diff --git a/Halgorithem/model_runtime.py b/Halgorithem/model_runtime.py new file mode 100644 index 0000000..38b588b --- /dev/null +++ b/Halgorithem/model_runtime.py @@ -0,0 +1,409 @@ +import os +import re +import warnings +from functools import lru_cache + +from sklearn.metrics.pairwise import cosine_similarity + +from .core import LocalEmbedder +from .models import AtomicClaim +from .nlp import SPACY_MODEL, nlp + + +def model_flag(name, default="1"): + return os.getenv(name, default).lower() in {"1", "true", "yes", "on"} + + +class SentenceEmbedder: + def __init__(self, model_name=None): + self.model_name = model_name or os.getenv("HALGORITHEM_RETRIEVAL_MODEL", "sentence-transformers/all-mpnet-base-v2") + self.kind = "sentence-transformer" + self.fallback_reason = None + self._local = None + if self.model_name.lower() in {"local", "lexical", "hashing"}: + self.kind = "lexical" + self.model_name = "HashingVectorizer" + self._model = None + self._local = LocalEmbedder() + return + try: + from sentence_transformers import SentenceTransformer, util + + allow_download = model_flag("HALGORITHEM_ALLOW_MODEL_DOWNLOAD", "0") + self._util = util + self._model = SentenceTransformer(self.model_name, local_files_only=not allow_download) + except Exception as exc: + warnings.warn( + f"Could not load retrieval embedder {self.model_name!r} ({exc}); using lexical hashing fallback.", + RuntimeWarning, + ) + self.kind = "lexical" + self.fallback_reason = str(exc) + self._model = None + self._local = LocalEmbedder() + + def encode(self, text): + if self._model is not None: + return self._model.encode(text or "", convert_to_tensor=True) + return self._local.encode(text or "", convert_to_tensor=True) + + def similarity(self, left, right): + if self._model is not None: + return float(self._util.cos_sim(left, right)) + return float(cosine_similarity(left, right)[0][0]) + + @property + def diagnostics(self): + return { + "retrieval_embedder": self.kind, + "retrieval_model": self.model_name if self.kind == "sentence-transformer" else "HashingVectorizer", + "retrieval_fallback_reason": self.fallback_reason, + } + + +class CrossEncoderReranker: + """Reranks a bi-encoder shortlist with a cross-encoder, falling back to original order offline.""" + + def __init__(self, model_name=None): + self.model_name = model_name or os.getenv("HALGORITHEM_CROSS_ENCODER_MODEL", "cross-encoder/ms-marco-MiniLM-L-6-v2") + self.kind = "cross-encoder" + self.fallback_reason = None + if self.model_name.lower() in {"none", "off", "disabled", "local", "passthrough"}: + self.kind = "passthrough" + self.model_name = "passthrough" + self._model = None + return + try: + from sentence_transformers import CrossEncoder + + allow_download = model_flag("HALGORITHEM_ALLOW_MODEL_DOWNLOAD", "0") + self._model = CrossEncoder(self.model_name, local_files_only=not allow_download) + except Exception as exc: + warnings.warn( + f"Could not load cross-encoder reranker {self.model_name!r} ({exc}); using bi-encoder order.", + RuntimeWarning, + ) + self.kind = "passthrough" + self.fallback_reason = str(exc) + self._model = None + + def rerank(self, query, items, *, text_fn, top_k=5): + if not items or self._model is None: + return list(items)[:top_k] + pairs = [(query or "", text_fn(item) or "") for item in items] + try: + scores = self._model.predict(pairs) + except Exception as exc: + self.kind = "passthrough" + self.fallback_reason = str(exc) + return list(items)[:top_k] + ranked = sorted(zip(items, scores), key=lambda pair: float(pair[1]), reverse=True) + return [item for item, _ in ranked[:top_k]] + + @property + def diagnostics(self): + return { + "cross_encoder_reranker": self.kind, + "cross_encoder_model": self.model_name if self.kind == "cross-encoder" else "passthrough", + "cross_encoder_fallback_reason": self.fallback_reason, + } + + +class CoreferenceResolver: + """Resolves pronouns with fastcoref when available, otherwise leaves text unchanged.""" + + def __init__(self): + self.kind = "spacy" + self.fallback_reason = None + self.model_name = os.getenv("HALGORITHEM_COREF_MODEL", "biu-nlp/f-coref") + if model_flag("HALGORITHEM_USE_COREF", "1"): + try: + from fastcoref.modeling import FCorefModel + from fastcoref import FCoref + + # Compatibility shim for newer transformers versions expecting this attribute. + if not hasattr(FCorefModel, "all_tied_weights_keys"): + FCorefModel.all_tied_weights_keys = {} + device = os.getenv("HALGORITHEM_COREF_DEVICE") or None + self._model = FCoref( + model_name_or_path=self.model_name, + device=device, + nlp=SPACY_MODEL or "en_core_web_sm", + enable_progress_bar=False, + ) + self.kind = "fastcoref" + except Exception as exc: + self._model = None + self.fallback_reason = str(exc) + else: + self._model = None + + def resolve_text(self, text): + if not text or self._model is None: + return text or "" + try: + preds = self._model.predict(texts=[text]) + clusters = preds[0].get_clusters(as_strings=False) + if not clusters: + return text + # replace each non-first mention with the antecedent text + chars = list(text) + replacements = [] + for cluster in clusters: + if len(cluster) < 2: + continue + antecedent_start, antecedent_end = cluster[0] + antecedent = text[antecedent_start:antecedent_end] + for mention_start, mention_end in cluster[1:]: + mention = text[mention_start:mention_end] + # only replace short pronouns, not full noun phrases + if len(mention.split()) <= 3: + replacements.append((mention_start, mention_end, antecedent)) + # apply replacements in reverse so indices stay valid + for start, end, replacement in sorted(replacements, reverse=True): + chars[start:end] = list(replacement) + return "".join(chars) + except Exception as exc: + self.fallback_reason = str(exc) + return text + + @property + def diagnostics(self): + return { + "coreference": self.kind, + "coreference_model": self.model_name if self.kind == "fastcoref" else None, + "spacy_model": SPACY_MODEL, + "coreference_fallback_reason": self.fallback_reason, + } + + +class RebelClaimExtractor: + def __init__(self, model_name=None): + self.model_name = model_name or os.getenv("HALGORITHEM_REBEL_MODEL", "Babelscape/rebel-large") + self.kind = "rebel" + self.fallback_reason = None + if self.model_name.lower() in {"rule", "local", "deterministic"}: + self.kind = "rule" + self.model_name = "rule" + self._tokenizer = None + self._model = None + return + try: + from transformers import AutoModelForSeq2SeqLM, AutoTokenizer + + allow_download = model_flag("HALGORITHEM_ALLOW_MODEL_DOWNLOAD", "0") + self._tokenizer = AutoTokenizer.from_pretrained(self.model_name, local_files_only=not allow_download) + self._model = AutoModelForSeq2SeqLM.from_pretrained(self.model_name, local_files_only=not allow_download) + except Exception as exc: + self.kind = "rule" + self.fallback_reason = str(exc) + self._tokenizer = None + self._model = None + + def extract(self, text): + if not text: + return [] + if self._model is not None: + try: + return self._extract_rebel(text) + except Exception as exc: + self.kind = "rule" + self.fallback_reason = str(exc) + return self._extract_rules(text) + + def _extract_rebel(self, text): + inputs = self._tokenizer(text, return_tensors="pt", truncation=True, max_length=512) + output = self._model.generate(**inputs, max_length=256, num_beams=3) + decoded = self._tokenizer.batch_decode(output, skip_special_tokens=False)[0] + return parse_rebel_output(decoded) or self._extract_rules(text) + + def _extract_rules(self, text): + claims = [] + patterns = [ + r"(?P[A-Za-z][A-Za-z0-9 .'-]{1,80}?)\s+(?:was|is)\s+(?Pcreated|invented|developed|discovered|founded|designed|maintained|located|priced)\s+(?:by|in|at)?\s*(?P[A-Za-z0-9][A-Za-z0-9 .'-]{0,80}?)(?:\.|,|$)", + r"(?P[A-Za-z][A-Za-z0-9 .'-]{1,80}?)\s+(?:is|was)\s+(?Plocated\s+)?(?:in|at)\s+(?P[A-Za-z][A-Za-z .'-]{1,80}?)(?:\.|,|$)", + r"(?P[A-Za-z][A-Za-z0-9 .'-]{1,80}?)\s+(?Phas|had|contains|weighs|costs)\s+(?P[^.]{1,100})(?:\.|$)", + r"(?P[A-Za-z][A-Za-z0-9 .'-]{1,80}?),\s*(?P[A-Za-z][A-Za-z .'-]{1,80}),\s*(?P\d+(?:\.\d+)?)", + ] + for pattern in patterns: + for match in re.finditer(pattern, text, flags=re.IGNORECASE): + subject = clean_part(match.group("subject")) + relation = normalize_relation(clean_part(match.group("relation"))) + obj = clean_part(match.group("object")) + if subject and relation and obj: + claims.append(AtomicClaim(subject=subject, relation=relation, object=obj, text=f"{subject} {relation} {obj}")) + if not claims: + doc = nlp(text) + root = next((t for t in doc if t.dep_ == "ROOT"), None) + subj = next((t for t in doc if t.dep_ in {"nsubj", "nsubjpass"}), None) + obj = next((t for t in doc if t.dep_ in {"dobj", "attr", "pobj"}), None) + if root and subj and obj: + claims.append(AtomicClaim(subject=subj.text, relation=root.lemma_, object=obj.text, text=text.strip())) + return dedupe_claims(claims) + + @property + def diagnostics(self): + return { + "claim_extractor": self.kind, + "claim_model": self.model_name if self.kind == "rebel" else "rule", + "claim_fallback_reason": self.fallback_reason, + } + + +class FactScoreDecomposer: + """Turns a sentence into standalone atomic English facts for NLI-friendly atomic checking.""" + + prompt_template = ( + "Decompose the following sentence into simple atomic facts.\n" + "Each fact should be a complete standalone English sentence.\n" + "Output one fact per line, nothing else.\n" + "Sentence: {sentence}" + ) + + def __init__(self, model_name=None): + self.model_name = model_name or os.getenv("HALGORITHEM_DECOMPOSER_MODEL", "google/flan-t5-base") + self.kind = "factscore" + self.fallback_reason = None + self._fallback = RebelClaimExtractor(model_name="rule") + if self.model_name.lower() in {"rule", "local", "deterministic"}: + self.kind = "rule" + self.model_name = "rule" + self._tokenizer = None + self._model = None + return + try: + from transformers import AutoModelForSeq2SeqLM, AutoTokenizer + + allow_download = model_flag("HALGORITHEM_ALLOW_MODEL_DOWNLOAD", "0") + self._tokenizer = AutoTokenizer.from_pretrained(self.model_name, local_files_only=not allow_download) + self._model = AutoModelForSeq2SeqLM.from_pretrained(self.model_name, local_files_only=not allow_download) + except Exception as exc: + warnings.warn( + f"Could not load FActScore decomposer {self.model_name!r} ({exc}); using rule-based extraction.", + RuntimeWarning, + ) + self.kind = "rule" + self.fallback_reason = str(exc) + self._tokenizer = None + self._model = None + + def extract(self, text): + if not text: + return [] + if self._model is None: + return self._fallback.extract(text) + prompt = self.prompt_template.format(sentence=text) + try: + inputs = self._tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512) + output = self._model.generate(**inputs, max_length=256, num_beams=3) + decoded = self._tokenizer.batch_decode(output, skip_special_tokens=True)[0] + facts = [ + clean_part(line) + for line in decoded.splitlines() + if 10 <= len(clean_part(line)) <= 150 + ] + claims = [AtomicClaim(subject="", relation="", object="", text=fact) for fact in facts] + return dedupe_claims(claims) or self._fallback.extract(text) + except Exception as exc: + self.kind = "rule" + self.fallback_reason = str(exc) + return self._fallback.extract(text) + + @property + def diagnostics(self): + return { + "claim_extractor": self.kind, + "claim_model": self.model_name if self.kind == "factscore" else "rule", + "claim_fallback_reason": self.fallback_reason, + "decomposer_model": self.model_name if self.kind == "factscore" else "rule", + "decomposer_fallback_reason": self.fallback_reason, + } + + +def clean_part(value): + return " ".join((value or "").strip(" .,;:-").split()) + + +def normalize_relation(value): + normalized = " ".join((value or "").lower().split()) + if normalized in {"", "in", "at", "located"}: + return "located" + return normalized + + +def is_valid_triplet(claim): + if not claim.subject and not claim.relation and not claim.object: + fact = clean_part(claim.text) + return 10 <= len(fact) <= 150 + subject = clean_part(claim.subject) + relation = clean_part(claim.relation) + obj = clean_part(claim.object) + if not subject or not relation or not obj: + return False + if "." in subject or "." in obj: + return False + if subject.lower() == obj.lower(): + return False + if len(subject) < 2 or len(obj) < 2: + return False + return True + + +def dedupe_claims(claims): + seen = set() + unique = [] + for claim in claims: + if not is_valid_triplet(claim): + continue + key = ( + claim.subject.lower(), + claim.relation.lower(), + claim.object.lower(), + claim.text.lower() if not (claim.subject or claim.relation or claim.object) else "", + ) + if key not in seen: + unique.append(claim) + seen.add(key) + return unique + + +def parse_rebel_output(text): + triplets = [] + current = {"subject": "", "relation": "", "object": ""} + field = None + tokens = text.replace("", "").replace("", "").split() + for token in tokens: + if token == "": + if all(current.values()): + triplets.append(AtomicClaim(**current, text=f"{current['subject']} {current['relation']} {current['object']}")) + current = {"subject": "", "relation": "", "object": ""} + field = "subject" + elif token == "": + field = "object" + elif token == "": + field = "relation" + elif field: + current[field] = clean_part(f"{current[field]} {token}") + if all(current.values()): + triplets.append(AtomicClaim(**current, text=f"{current['subject']} {current['relation']} {current['object']}")) + return dedupe_claims(triplets) + + +@lru_cache(maxsize=1) +def default_coref(): + return CoreferenceResolver() + + +@lru_cache(maxsize=1) +def default_claim_extractor(): + return FactScoreDecomposer() + + +@lru_cache(maxsize=1) +def default_embedder(): + return SentenceEmbedder() + + +@lru_cache(maxsize=1) +def default_reranker(): + return CrossEncoderReranker() diff --git a/Halgorithem/models.py b/Halgorithem/models.py new file mode 100644 index 0000000..51730ca --- /dev/null +++ b/Halgorithem/models.py @@ -0,0 +1,101 @@ +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict, Field + + +Verdict = Literal["ENTAIL", "NEUTRAL", "CONTRADICT"] +FinalVerdict = Literal["SUPPORTED", "HALLUCINATED", "UNVERIFIABLE"] + + +class AtomicClaim(BaseModel): + subject: str = "" + relation: str = "" + object: str = "" + text: str + + +class DocumentSentence(BaseModel): + model_config = ConfigDict(arbitrary_types_allowed=True) + + index: int + text: str + resolved_text: str + source: str = "" + source_quality: float = 0.65 + claims: list[AtomicClaim] = Field(default_factory=list) + embedding: Any = Field(default=None, exclude=True) + + +class IngestedDocument(BaseModel): + sentences: list[DocumentSentence] + claims: list[AtomicClaim] + diagnostics: dict[str, Any] = Field(default_factory=dict) + + +class ProcessedSentence(BaseModel): + index: int + text: str + resolved_text: str + claims: list[AtomicClaim] = Field(default_factory=list) + + +class ProcessedResponse(BaseModel): + sentences: list[ProcessedSentence] + diagnostics: dict[str, Any] = Field(default_factory=dict) + + +class SimilarityHit(BaseModel): + sentence_index: int + sentence: str + score: float + source: str = "" + source_quality: float = 0.65 + + +class SimilarityCheck(BaseModel): + score: float + evidence: str = "" + source: str = "" + source_quality: float = 0.65 + hits: list[SimilarityHit] = Field(default_factory=list) + + +class NLICheck(BaseModel): + verdict: Verdict + confidence: float + evidence: str = "" + evidence_index: int | None = None + unit_mismatch: bool = False + unit_representation_change: bool = False + unit_details: list[dict[str, Any]] = Field(default_factory=list) + + +class AtomicClaimResult(BaseModel): + claim: str + verdict: Verdict + confidence: float + evidence: str = "" + + +class AtomicCheck(BaseModel): + claims: list[AtomicClaimResult] = Field(default_factory=list) + score: float | None = None + evidence: str = "" + + +class SentenceVerification(BaseModel): + sentence: str + similarity_score: float + entropy_score: float = 1.0 + source: str = "" + source_quality: float = 0.65 + nli_verdict: Verdict + nli_confidence: float + atomic_claims: list[AtomicClaimResult] + final_verdict: FinalVerdict + confidence: float + evidence: str + unit_mismatch: bool = False + unit_representation_change: bool = False + unit_details: list[dict[str, Any]] = Field(default_factory=list) + diagnostics: dict[str, Any] = Field(default_factory=dict) diff --git a/Halgorithem/nlp.py b/Halgorithem/nlp.py index de66382..044b32b 100644 --- a/Halgorithem/nlp.py +++ b/Halgorithem/nlp.py @@ -8,7 +8,7 @@ def _load_spacy_model(): global SPACY_MODEL, SPACY_MODEL_WARNING - for model_name in ("en_core_web_lg", "en_core_web_sm"): + for model_name in ("en_core_web_trf", "en_core_web_lg", "en_core_web_sm"): try: SPACY_MODEL = model_name return spacy.load(model_name) @@ -17,9 +17,9 @@ def _load_spacy_model(): SPACY_MODEL = "blank_en" SPACY_MODEL_WARNING = ( - "spaCy model 'en_core_web_lg' or 'en_core_web_sm' is not installed; " + "spaCy model 'en_core_web_trf', 'en_core_web_lg', or 'en_core_web_sm' is not installed; " "falling back to spacy.blank('en'). Install one with " - "'python -m spacy download en_core_web_sm' for better accuracy." + "'python -m spacy download en_core_web_trf' for better accuracy." ) blank = spacy.blank("en") blank.add_pipe("sentencizer") diff --git a/Halgorithem/process.py b/Halgorithem/process.py new file mode 100644 index 0000000..c197059 --- /dev/null +++ b/Halgorithem/process.py @@ -0,0 +1,26 @@ +from .core import Halgorithm, LocalEmbedder +from .model_runtime import default_claim_extractor, default_coref +from .models import ProcessedResponse, ProcessedSentence + + +def process_response(response_text, *, coref=None, extractor=None): + splitter = Halgorithm(sentences_per_chunk=1, sentence_overlap=0, embedder=LocalEmbedder()) + coref = coref or default_coref() + extractor = extractor or default_claim_extractor() + + sentences = [] + for index, sentence in enumerate(splitter.split_sentences(response_text), 1): + resolved = coref.resolve_text(sentence) + sentences.append( + ProcessedSentence( + index=index, + text=sentence, + resolved_text=resolved, + claims=extractor.extract(resolved), + ) + ) + + diagnostics = {} + diagnostics.update(coref.diagnostics) + diagnostics.update(extractor.diagnostics) + return ProcessedResponse(sentences=sentences, diagnostics=diagnostics) diff --git a/Halgorithem/retrieval.py b/Halgorithem/retrieval.py index f5e1684..77ff3a6 100644 --- a/Halgorithem/retrieval.py +++ b/Halgorithem/retrieval.py @@ -1,3 +1,25 @@ +import re + + +TOKEN_ALIASES = { + "delivered": "made", + "deliver": "made", + "renowned": "famous", + "well-known": "famous", + "speech": "speech", + "population": "population", + "located": "located", +} + + +def _tokens(text): + return { + TOKEN_ALIASES.get(token, token) + for token in re.findall(r"[a-z0-9]+(?:-[a-z0-9]+)?", (text or "").lower()) + if len(token) > 2 + } + + def rank_chunks( claim, chunks, @@ -6,6 +28,7 @@ def rank_chunks( has_negation_mismatch, threshold=0.30, top_k=5, + reranker=None, ): candidates = [] claim_numbers = set(extract_numbers(claim)) @@ -14,8 +37,8 @@ def rank_chunks( raw_score = score_fn(claim, chunk) score = raw_score signals = [] - claim_tokens = {t.lower() for t in claim.replace(".", " ").replace(",", " ").split() if t.strip()} - chunk_tokens = set(chunk.get("tokens", [])) + claim_tokens = _tokens(claim) + chunk_tokens = {TOKEN_ALIASES.get(t, t) for t in set(chunk.get("tokens", []))} | _tokens(chunk.get("text", "")) content_tokens = {t for t in claim_tokens if len(t) > 2} if content_tokens: overlap = len(content_tokens & chunk_tokens) / len(content_tokens) @@ -30,6 +53,10 @@ def rank_chunks( score = min(score + 0.10, 1.0) signals.append("number_subset") + if claim_numbers and claim_numbers & set(chunk.get("numbers", [])) and len(content_tokens & chunk_tokens) >= 2: + score = min(score + 0.12, 1.0) + signals.append("number_anchor_overlap") + if has_negation_mismatch(claim, chunk.get("text", "")) and score >= threshold: score = max(score - 0.30, 0.0) signals.append("negation_penalty") @@ -41,4 +68,15 @@ def rank_chunks( "signals": signals, }) - return sorted(candidates, key=lambda c: c["score"], reverse=True)[:top_k] + ranked = sorted(candidates, key=lambda c: c["score"], reverse=True) + if reranker is None: + try: + from .model_runtime import default_reranker + + reranker = default_reranker() + except Exception: + reranker = None + if reranker is not None: + shortlist = ranked[:max(20, top_k)] + return reranker.rerank(claim, shortlist, text_fn=lambda item: item["chunk"].get("text", ""), top_k=top_k) + return ranked[:top_k] diff --git a/Halgorithem/text_processing.py b/Halgorithem/text_processing.py index 4127652..cb98e15 100644 --- a/Halgorithem/text_processing.py +++ b/Halgorithem/text_processing.py @@ -129,7 +129,7 @@ def has_negation_mismatch(claim, chunk_text): if not claim_has_negation and not chunk_has_negation: negation_terms = { "no", "not", "never", "neither", "nor", "without", "didn't", - "doesn't", "wasn't", "isn't", "aren't", "can't", "cannot", "did" + "doesn't", "wasn't", "isn't", "aren't", "can't", "cannot" } claim_tokens = {t.text.lower() for t in claim_doc} chunk_tokens = {t.text.lower() for t in chunk_doc} diff --git a/Halgorithem/voting.py b/Halgorithem/voting.py new file mode 100644 index 0000000..1063079 --- /dev/null +++ b/Halgorithem/voting.py @@ -0,0 +1,119 @@ +import re + +from .models import AtomicCheck, FinalVerdict, NLICheck, SimilarityCheck + + +SIMILARITY_THRESHOLD = 0.4 +NLI_CONFIDENCE_THRESHOLD = 0.6 +SUPPORTED_THRESHOLD = 0.68 +HALLUCINATED_THRESHOLD = 0.38 + + +def entropy_gate(sentence_text, embedder, threshold=0.85): + """Checks deterministic paraphrase embedding consistency before expensive verification work.""" + parts = [part.strip() for part in re.split(r"\s*(?:,|;|\band\b)\s*", sentence_text or "") if part.strip()] + if len(parts) <= 1: + paraphrases = [sentence_text or ""] * 5 + else: + paraphrases = [ + " ".join(parts), + "; ".join(parts), + ", ".join(reversed(parts)), + f"{parts[0]} and {' '.join(parts[1:])}", + " ".join(part for part in sorted(parts, key=str.lower)), + ] + embeddings = [embedder.encode(text) for text in paraphrases[:5]] + scores = [] + for left_index, left in enumerate(embeddings): + for right in embeddings[left_index + 1:]: + scores.append(embedder.similarity(left, right)) + entropy_score = sum(scores) / len(scores) if scores else 1.0 + if entropy_score < threshold: + return "UNVERIFIABLE", 0.5, entropy_score + return None, None, entropy_score + + +def nli_score(check: NLICheck): + if check.confidence < NLI_CONFIDENCE_THRESHOLD: + return None + if check.verdict == "ENTAIL": + return check.confidence + if check.verdict == "CONTRADICT": + return 1.0 - check.confidence + return 0.5 + + +def similarity_score(check: SimilarityCheck): + if check.score < SIMILARITY_THRESHOLD: + return None + return check.score + + +def similarity_weight(check: SimilarityCheck): + quality = max(0.0, min(check.source_quality, 1.0)) + return 0.2 * (0.75 + 0.25 * quality) + + +def atomic_score(check: AtomicCheck): + return check.score + + +def has_strong_atomic_entailment(check: AtomicCheck): + return any(result.verdict == "ENTAIL" and result.confidence >= 0.85 for result in check.claims) + + +def has_strong_atomic_contradiction(check: AtomicCheck): + return any(result.verdict == "CONTRADICT" and result.confidence >= 0.85 for result in check.claims) + + +def fuse_votes(similarity: SimilarityCheck, nli: NLICheck, atomic: AtomicCheck): + weighted = [] + sim = similarity_score(similarity) + sent = nli_score(nli) + atom = atomic_score(atomic) + if sim is not None: + weighted.append((similarity_weight(similarity), sim)) + if sent is not None: + weighted.append((0.5, sent)) + if atom is not None: + weighted.append((0.3, atom)) + + if not weighted: + return "UNVERIFIABLE", 0.0 + + weight_total = sum(weight for weight, _ in weighted) + final_score = sum(weight * score for weight, score in weighted) / weight_total + + strong_atomic_entail = has_strong_atomic_entailment(atomic) + nli_override_is_contested = ( + nli.verdict == "CONTRADICT" + and nli.confidence >= 0.85 + and similarity.score >= 0.85 + and strong_atomic_entail + ) + + if nli.verdict == "CONTRADICT" and nli.confidence >= 0.85 and not nli_override_is_contested: + return "HALLUCINATED", max(nli.confidence, 1.0 - final_score) + if has_strong_atomic_contradiction(atomic): + return "HALLUCINATED", max(result.confidence for result in atomic.claims if result.verdict == "CONTRADICT") + if final_score >= SUPPORTED_THRESHOLD: + return "SUPPORTED", final_score + if final_score <= HALLUCINATED_THRESHOLD: + return "HALLUCINATED", 1.0 - final_score + return "UNVERIFIABLE", 1.0 - abs(0.5 - final_score) * 2 + + +def choose_evidence(similarity: SimilarityCheck, nli: NLICheck, atomic: AtomicCheck, final_verdict: FinalVerdict): + if final_verdict == "HALLUCINATED": + if nli.verdict == "CONTRADICT" and nli.evidence: + return nli.evidence + for result in atomic.claims: + if result.verdict == "CONTRADICT" and result.evidence: + return result.evidence + if final_verdict == "UNVERIFIABLE" and nli.evidence: + return nli.evidence + if nli.verdict == "ENTAIL" and nli.evidence: + return nli.evidence + if atomic.evidence: + return atomic.evidence + return similarity.evidence diff --git a/Halgorithem/web.py b/Halgorithem/web.py index 3d0a73a..42045c8 100644 --- a/Halgorithem/web.py +++ b/Halgorithem/web.py @@ -1,10 +1,12 @@ from bs4 import BeautifulSoup import requests import html2text +from pathlib import Path class WebScraper: - def __init__(self, list_of_urls): + def __init__(self, list_of_urls, output_dir="."): self.urls = list_of_urls + self.output_dir = Path(output_dir) self.converter = html2text.HTML2Text() self.converter.ignore_links = True self.converter.ignore_images = True @@ -12,6 +14,8 @@ def __init__(self, list_of_urls): self.counter = 0 def scrape(self): + results = [] + self.output_dir.mkdir(parents=True, exist_ok=True) headers = { "User-Agent": "Mozilla/5.0 (compatible; HalgorithemBot/1.0)" } @@ -36,15 +40,25 @@ def scrape(self): plain_text = self.converter.handle(str(soup)) plain_text = plain_text[:8000] # cap non-wiki sources - with open(f"file{self.counter}.txt", "w", - encoding="utf-8") as f: + file_path = self.output_dir / f"file{self.counter}.txt" + with file_path.open("w", encoding="utf-8") as f: f.write(plain_text) - print(f"Scraped: {url} → file{self.counter}.txt") + print(f"Scraped: {url} → {file_path.name}") + results.append({"url": url, "file_path": str(file_path), "text": plain_text, "ok": True, "error": None}) self.counter += 1 except requests.exceptions.Timeout: print(f"Timeout: {url}") + results.append({"url": url, "file_path": None, "text": "", "ok": False, "error": "timeout"}) except requests.exceptions.HTTPError as e: print(f"HTTP error {e}: {url}") + results.append({"url": url, "file_path": None, "text": "", "ok": False, "error": str(e)}) except Exception as e: - print(f"Failed {url}: {e}") \ No newline at end of file + print(f"Failed {url}: {e}") + results.append({"url": url, "file_path": None, "text": "", "ok": False, "error": str(e)}) + return results + + +def scrape_url_texts(urls, output_dir="."): + """Scrapes URL sources into text records that can feed the PRISM verifier directly.""" + return [result for result in WebScraper(urls, output_dir=output_dir).scrape() if result.get("ok") and result.get("text")] diff --git a/README (1).md b/README (1).md new file mode 100644 index 0000000..5361fa6 --- /dev/null +++ b/README (1).md @@ -0,0 +1,242 @@ +Halgorithem logo + +Halgorithem (codename **CORE** — Claim-Oriented Recognition Engine) is a deterministic hallucination detection library for checking AI output against trusted source material. It extracts factual claims from AI-generated text, retrieves the closest evidence from your documents, and labels each claim as supported, weakly supported, contradicted, hallucinated, or an unverifiable denial. Verification is meaning-based by default, using sentence-transformer embeddings to match paraphrases, with deterministic guardrails for names, numbers, dates, units, negation, and source qualifiers. + +## Documentation + +Full API reference, design notes, and usage examples can be found in the [docs](docs/). The output schema, verdict definitions, and benchmark details are covered below. + +## Forums & Community + +Have a question, idea, or bug report? Open a [GitHub Issue](https://github.com/TangibleResearch/Halgorithem/issues) or start a [Discussion](https://github.com/TangibleResearch/Halgorithem/discussions). Please review the [Code of Conduct](CODE_OF_CONDUCT.md) before participating. + +## Contributing + +Contributions to the codebase, benchmark datasets, and documentation are all welcome. See the [contributing guide](CONTRIBUTING.md) for details on how to get started. + +## Getting Started + +### Install + +```bash +python -m pip install -e . +``` + +Recommended NLP model: + +```bash +python -m spacy download en_core_web_lg +``` + +Lightweight fallback: + +```bash +python -m spacy download en_core_web_sm +``` + +If neither spaCy model is installed, Halgorithem falls back to `spacy.blank("en")` with reduced linguistic accuracy rather than crashing. + +### Quick Start + +```python +from Halgorithem import Halgorithm + +algo = Halgorithm() + +results = algo.compare_to_docs( + truth_docs=[ + { + "file_id": 1, + "file_path": "source.txt", + "text": "BASIC was created in 1964 by John Kemeny at Dartmouth College.", + } + ], + ai_output="BASIC was created in 1972 by NASA.", +) + +for result in results: + print(result["status"], result["claim"], result["reason"]) +``` + +## Python API + +```python +from Halgorithem import Halgorithm + +algo = Halgorithm(sentences_per_chunk=2, sentence_overlap=1) +``` + +Verify in-memory documents: + +```python +algo.compare_to_docs( + truth_docs="BASIC was created in 1964.", + ai_output="BASIC was created in 1964.", +) +``` + +Verify files on disk: + +```python +algo.compare_to_files( + truth_file_paths=["sources/basic.txt"], + ai_output="BASIC was created by NASA.", +) +``` + +Optional generation wrapper (may call OpenAI; the verifier itself remains fully deterministic): + +```python +from engine import run + +result = run( + prompt="Summarize this source.", + truth_file_paths=["sources/basic.txt"], +) +``` + +## Verification Mode + +By default, Halgorithem loads `sentence-transformers/all-MiniLM-L6-v2` from the local model cache and verifies claims by semantic similarity rather than strict word matching. + +```bash +HALGORITHEM_EMBEDDER=semantic python tui.py +``` + +To allow the model to download when missing from the local cache: + +```bash +HALGORITHEM_ALLOW_MODEL_DOWNLOAD=1 python tui.py +``` + +For fully lexical, deterministic fallback behavior: + +```bash +HALGORITHEM_EMBEDDER=local python tui.py +``` + +## CORE Pipeline + +The newer deterministic pipeline is available through `HalgorithemVerifier` and the JSON CLI. It runs document ingestion once, then processes each AI sentence through three independent checks: + +- Similarity retrieval with `sentence-transformers/all-mpnet-base-v2` +- Sentence-level NLI with `cross-encoder/nli-deberta-v3-large` +- Atomic claim NLI over REBEL-style triplets from `Babelscape/rebel-large` + +All model loads are local/offline by default. If REBEL, DeBERTa, mpnet, or Coreferee are not installed or cached, Halgorithem falls back to deterministic local checks and surfaces that in `diagnostics`. + +```bash +python main.py --document doc.txt --response response.txt +``` + +```python +from Halgorithem import HalgorithemVerifier + +results = HalgorithemVerifier().verify(document_text, response_text) +print([result.model_dump(mode="json") for result in results]) +``` + +## CLI Usage + +Interactive terminal UI: + +```bash +python tui.py +``` + +Benchmark runner: + +```bash +python bench.py +``` + +## Tests + +```bash +python -m pytest +``` + +The test suite is designed to be fully network-free and uses local documents only. + +## Output Schema + +Every claim result includes: + +```python +{ + "claim": str, + "status": "SUPPORTED | WEAK_SUPPORT | CONTRADICTION | HALLUCINATION | UNVERIFIABLE_DENIAL | ERROR", + "confidence": float, + "score": float, + "matched_source": str | None, + "matched_chunk_id": int | None, + "matched_chunk": str, + "chunk_text": str, + "evidence": list, + "unsupported_terms": list[str], + "reason": str, + "warning": str | None, +} +``` + +### Verdict Meanings + +| Verdict | Meaning | +|:-------:|---------| +| `SUPPORTED` | Strong evidence is present in the supplied sources. | +| `WEAK_SUPPORT` | Related evidence exists, but the claim is inferential or not fully direct. | +| `CONTRADICTION` | Relevant source evidence conflicts with the claim. | +| `HALLUCINATION` | The claim lacks adequate source support. | +| `UNVERIFIABLE_DENIAL` | The claim denies a fact or entity absent from the sources — absence alone cannot prove it. | +| `ERROR` | The verifier could not parse or evaluate the claim (mostly malformed math). | + +## Benchmark + +`bench.py` runs a release benchmark across the following categories: + +- Supported claims +- Paraphrases +- Weak support +- Hallucinations +- Date mismatches +- Entity-role swaps +- Unit errors +- Current/latest claims +- Table-like facts +- Multi-source disagreement +- Denial claims +- Missing-source cases + +It reports accuracy, per-category accuracy, a confusion matrix, failures, temporal warning checks, and a pass/fail threshold. + +## Runtime Hardening + +Halgorithem handles the following gracefully: + +- Missing or empty files +- Empty AI output +- Malformed `truth_docs` or missing `text` fields +- Bad UTF-8 file encodings +- No extracted claims +- Math parse errors +- Missing spaCy or embedding models + +## Limitations + +- Rule-based entity-role detection handles common simple patterns, not arbitrary grammar. +- Semantic verification depends on a local sentence-transformer model; the lexical hashing fallback is deterministic and CI-safe but less meaning-aware. +- Multi-source disagreement is only surfaced when the source qualifier is explicit. +- Table-like facts work best when row values are near each other in the source text. +- Current/latest claims are warned about, not externally refreshed. + +## Roadmap + +- Optional structured table parser +- Better contradiction handling for passive and nested clauses +- Calibrated benchmark sets by domain +- Machine-readable benchmark artifacts +- Additional CLI commands beyond the interactive TUI + +## Release Readiness + +A v1.0 release requires: tests passing, benchmark meeting its threshold, CI green, packaging installing cleanly, README limitations documented, and the release checklist complete. diff --git a/README.md b/README.md deleted file mode 100644 index 71a6971..0000000 --- a/README.md +++ /dev/null @@ -1,208 +0,0 @@ -# Halgorithem - -Deterministic hallucination detection for checking AI output against trusted source material. - -Halgorithem takes source documents and AI output, extracts factual claims, retrieves the closest evidence, and labels each claim as supported, weakly supported, contradicted, hallucinated, or an unverifiable denial. - -## What It Does - -- Verifies AI-generated factual claims against supplied sources. -- Splits multi-fact text into atomic claims. -- Retrieves evidence chunks from one or more sources. -- Detects date, number, unit, negation, source-qualifier, and simple entity-role conflicts. -- Flags time-sensitive claims such as "current", "latest", "today", and "now". -- Returns a structured result for every extracted claim. - -## What It Does Not Do - -- It does not prove truth in the real world. -- It does not browse the web unless you use the optional URL wrapper. -- It does not replace source quality review. -- It does not guarantee perfect paraphrase understanding, especially with the local fallback embedder. -- It does not use an LLM for verification. - -## Install - -```bash -python -m pip install -e . -``` - -Recommended NLP model: - -```bash -python -m spacy download en_core_web_lg -``` - -Lightweight fallback: - -```bash -python -m spacy download en_core_web_sm -``` - -If neither spaCy model is installed, Halgorithem falls back to `spacy.blank("en")` with reduced linguistic accuracy instead of crashing. - -## Quick Start - -```python -from Halgorithem import Halgorithm - -algo = Halgorithm() - -results = algo.compare_to_docs( - truth_docs=[ - { - "file_id": 1, - "file_path": "source.txt", - "text": "BASIC was created in 1964 by John Kemeny at Dartmouth College.", - } - ], - ai_output="BASIC was created in 1972 by NASA.", -) - -for result in results: - print(result["status"], result["claim"], result["reason"]) -``` - -## Python API - -```python -from Halgorithem import Halgorithm - -algo = Halgorithm(sentences_per_chunk=2, sentence_overlap=1) -``` - -Verify in-memory documents: - -```python -algo.compare_to_docs( - truth_docs="BASIC was created in 1964.", - ai_output="BASIC was created in 1964.", -) -``` - -Verify files: - -```python -algo.compare_to_files( - truth_file_paths=["sources/basic.txt"], - ai_output="BASIC was created by NASA.", -) -``` - -Optional generation wrapper: - -```python -from engine import run - -result = run( - prompt="Summarize this source.", - truth_file_paths=["sources/basic.txt"], -) -``` - -The wrapper may call OpenAI for generation. The verifier in `Halgorithem/` remains deterministic. - -## CLI Usage - -Interactive terminal UI: - -```bash -python tui.py -``` - -Benchmark: - -```bash -python bench.py -``` - -## Tests - -```bash -python -m pytest -``` - -The pytest suite is designed to be network-free and uses local documents. - -## Benchmark - -`bench.py` runs a release benchmark across: - -- supported claims -- paraphrases -- weak support -- hallucinations -- date mismatches -- entity-role swaps -- unit errors -- current/latest claims -- table-like facts -- multi-source disagreement -- denial claims -- missing-source cases - -It reports accuracy, accuracy by category, a confusion matrix, failures, temporal warning checks, and a pass/fail threshold. - -## Output Schema - -Every claim result includes: - -```python -{ - "claim": str, - "status": "SUPPORTED | WEAK_SUPPORT | CONTRADICTION | HALLUCINATION | UNVERIFIABLE_DENIAL | ERROR", - "confidence": float, - "score": float, - "matched_source": str | None, - "matched_chunk_id": int | None, - "matched_chunk": str, - "chunk_text": str, - "evidence": list, - "unsupported_terms": list[str], - "reason": str, - "warning": str | None, -} -``` - -## Verdict Meanings - -- `SUPPORTED`: strong evidence is present in the supplied sources. -- `WEAK_SUPPORT`: related evidence exists, but the claim is inferential or not fully direct. -- `CONTRADICTION`: relevant source evidence conflicts with the claim. -- `HALLUCINATION`: the claim lacks adequate source support. -- `UNVERIFIABLE_DENIAL`: the claim denies a fact or entity absent from the sources, so absence alone cannot prove it. -- `ERROR`: the verifier could not parse or evaluate the claim, mostly for malformed math. - -## Runtime Hardening - -Halgorithem handles: - -- missing files -- empty sources -- empty AI output -- malformed `truth_docs` -- missing `text` fields -- bad UTF-8 file encodings -- no extracted claims -- math parse errors -- missing spaCy or embedding models - -## Limitations - -- Rule-based entity-role detection handles common simple patterns, not arbitrary grammar. -- Local hashing embeddings are deterministic and CI-safe but less semantic than sentence-transformers. -- Multi-source disagreement is surfaced when the source qualifier is explicit. -- Table-like facts work best when row values are near each other in text. -- Current/latest claims are warned, not externally refreshed. - -## Roadmap - -- Optional structured table parser. -- Better contradiction handling for passive and nested clauses. -- Calibrated benchmark sets by domain. -- Machine-readable benchmark artifacts. -- More CLI commands beyond the interactive TUI. - -## Release Readiness - -v1.0 readiness means tests pass, the benchmark meets its threshold, CI passes, packaging installs, README limitations are documented, and the release checklist is complete. diff --git a/assets/Halgorithem.png b/assets/Halgorithem.png new file mode 100644 index 0000000000000000000000000000000000000000..eed5d75d25371ab7bdde779e62624bc5352fb7de GIT binary patch literal 59665 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≥EFY#>dBHpyci1>ws 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 d88K5J0V$yaPeeEBHh^zH*VJ@Wi=4.12", "clean-text>=0.6", + "fastcoref>=2.1", "html2text>=2024.2.26", "markdown-it-py>=3", "negspacy>=1.0", "nltk>=3.8", "openai>=1.0", + "pydantic>=2", "pysbd>=0.3", "quantulum3[classifier]>=0.9", "requests>=2.31", @@ -27,13 +30,20 @@ dependencies = [ "scikit-learn>=1.3", "sentence-transformers>=2.7", "spacy>=3.7", + "spacy-curated-transformers>=0.3", "sympy>=1.12", "textacy>=0.13", "stemming>=1.0", + "torch>=2", + "transformers>=4", ] +[project.scripts] +halgorithem = "Halgorithem.main:main" + [project.optional-dependencies] dev = ["pytest>=8"] +advanced = ["spacy-curated-transformers>=0.3"] [tool.setuptools.packages.find] include = ["Halgorithem*"] diff --git a/requirements.txt b/requirements.txt index e07f5a7..17e07b3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,13 @@ beautifulsoup4>=4.12 +fastcoref>=2.1 +numpy<2 clean-text>=0.6 html2text>=2024.2.26 markdown-it-py>=3 negspacy>=1.0 nltk>=3.8 openai>=1.0 +pydantic>=2 pysbd>=0.3 quantulum3[classifier]>=0.9 requests>=2.31 @@ -12,12 +15,15 @@ rich>=13 scikit-learn>=1.3 sentence-transformers>=2.7 spacy>=3.7 +spacy-curated-transformers>=0.3 sympy>=1.12 textacy>=0.13 stemming>=1.0 +torch>=2 +transformers>=4 # Optional but recommended for best NLP accuracy: -# python -m spacy download en_core_web_lg +# python -m spacy download en_core_web_trf # # CI and lightweight installs may use: # python -m spacy download en_core_web_sm diff --git a/tests/__pycache__/test_halgorithem.cpython-312-pytest-9.0.3.pyc b/tests/__pycache__/test_halgorithem.cpython-312-pytest-9.0.3.pyc deleted file mode 100644 index 1f34cf15888cd9d1e4e92d5ed7a5b1e3b6081d73..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 17175 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 diff --git a/tests/test_core_pipeline.py b/tests/test_core_pipeline.py new file mode 100644 index 0000000..cdacd4d --- /dev/null +++ b/tests/test_core_pipeline.py @@ -0,0 +1,65 @@ +from Halgorithem.checks.nli import rule_nli +from Halgorithem.checks.units import normalize_units, unit_representation_mismatch +from Halgorithem.ingest import ingest_document +from Halgorithem.model_runtime import RebelClaimExtractor, dedupe_claims +from Halgorithem.models import AtomicClaim + + +def test_rule_claim_extractor_normalizes_short_location_relation(): + extractor = RebelClaimExtractor(model_name="rule") + + claims = extractor.extract("Lima is in Peru.") + + assert len(claims) == 1 + assert claims[0].subject.lower() == "lima" + assert claims[0].relation == "located" + assert claims[0].object.lower() == "peru" + + +def test_rule_nli_contradicts_short_and_explicit_location_mismatch(): + verdict, confidence = rule_nli("Lima is in Peru.", "Lima is located in Japan.") + + assert verdict == "CONTRADICT" + assert confidence >= 0.8 + + +def test_unit_normalization_matches_equivalent_rewrites(): + normalized, changes = normalize_units("The Mars sample container has a mass of 10000 grams.") + + assert "10 kilogram" in normalized + assert changes[0]["original"] == "10000 grams" + assert changes[0]["normalized"] == "10 kilogram" + + +def test_unit_representation_mismatch_flags_equivalent_rewrite(): + mismatch = unit_representation_mismatch( + "The Mars sample container has a mass of 10 kilograms.", + "The Mars sample container has a mass of 10000 grams.", + ) + + assert mismatch["source"] == "10 kilograms" + assert mismatch["response"] == "10000 grams" + assert mismatch["normalized"] == "10 kilogram" + + +def test_rebel_triplet_filter_rejects_malformed_artifacts(): + claims = dedupe_claims( + [ + AtomicClaim(subject="nasa.basic", relation="owned by", object="nasa", text="bad"), + AtomicClaim(subject="nasa", relation="owner of", object="nasa.basic", text="bad"), + AtomicClaim(subject="Lima", relation="country", object="Peru", text="good"), + ] + ) + + assert [claim.text for claim in claims] == ["good"] + + +def test_ingest_records_source_quality(): + document = ingest_document( + "NASA launched the test mission.", + source_name="https://www.nasa.gov/example", + extractor=RebelClaimExtractor(model_name="rule"), + ) + + assert document.sentences[0].source == "https://www.nasa.gov/example" + assert document.sentences[0].source_quality > 0.7 diff --git a/tests/test_halgorithem.py b/tests/test_halgorithem.py index 9edfc89..4ed4300 100644 --- a/tests/test_halgorithem.py +++ b/tests/test_halgorithem.py @@ -1,8 +1,10 @@ import pytest +import tui from Halgorithem import Halgorithm from Halgorithem.claim_extraction import split_atomic_claims from Halgorithem.contradiction import find_contradiction +from Halgorithem.core import LocalEmbedder from Halgorithem.retrieval import rank_chunks @@ -56,6 +58,10 @@ def test_supported_claim(algo, docs): assert first_status(algo, docs, "BASIC was created in 1964.") == "SUPPORTED" +def test_semantic_paraphrase_support(algo, docs): + assert first_status(algo, docs, "John Kemeny developed BASIC at Dartmouth in 1964.") == "SUPPORTED" + + def test_weak_support(algo, docs): assert first_status(algo, docs, "BASIC helped beginners learn programming.") == "WEAK_SUPPORT" @@ -80,6 +86,11 @@ def test_unit_contradiction(algo, docs): assert result["reason"] == "Unit mismatch" +def test_unit_conversion_support(algo, docs): + result = algo.compare_to_docs(docs, "The sample has a mass of 10000 grams.")[0] + assert result["status"] == "SUPPORTED" + + def test_math_checks(algo): supported = algo.compare_to_docs("Math source.", "2 + 2 = 4.")[0] contradicted = algo.compare_to_docs("Math source.", "2 + 2 = 5.")[0] @@ -89,6 +100,19 @@ def test_math_checks(algo): assert malformed["status"] == "ERROR" +def test_percent_claims_are_verified_as_source_claims(algo): + docs = "The drug reduced mortality by 20 percent." + result = algo.compare_to_docs(docs, "The drug reduced mortality by 20 percent.")[0] + assert result["type"] == "SOURCE" + assert result["status"] == "SUPPORTED" + + +def test_math_rejects_unsafe_symbols(algo): + result = algo.verify_math_claim("__import__('os') = 1") + assert result["status"] == "ERROR" + assert "unsupported symbols" in result["reason"] + + def test_temporal_warning(algo, docs): result = algo.compare_to_docs(docs, "The current status of Project Helios is active.")[0] assert result["warning"] == "Time-sensitive claim" @@ -119,6 +143,10 @@ def test_compare_to_files(algo, tmp_path): def test_runtime_hardening_errors(algo, tmp_path): + with pytest.raises(ValueError): + Halgorithm(sentences_per_chunk=0, sentence_overlap=0) + with pytest.raises(ValueError): + Halgorithm(sentences_per_chunk=1, sentence_overlap=1) with pytest.raises(FileNotFoundError): algo.compare_to_files([str(tmp_path / "missing.txt")], "A claim.") with pytest.raises(ValueError): @@ -126,3 +154,76 @@ def test_runtime_hardening_errors(algo, tmp_path): with pytest.raises(ValueError): algo.compare_to_docs([{"file_path": "bad"}], "A claim.") assert algo.compare_to_docs("A source.", "") == [] + + +def test_location_mismatch(algo): + docs = "Lima, Peru, 9752000. Tokyo, Japan, 14000000." + result = algo.compare_to_docs(docs, "Lima is located in Japan.")[0] + assert result["status"] == "CONTRADICTION" + assert result["reason"] == "Location mismatch" + + +def test_missing_table_entity_with_number_is_hallucination(algo): + docs = "Product A price 19 USD. Product B price 25 USD." + result = algo.compare_to_docs(docs, "Product C has price 31 USD.")[0] + assert result["status"] == "HALLUCINATION" + assert "c" in result["unsupported_terms"] + + +def test_britannica_nehru_paraphrase_with_lexical_fallback(): + algo = Halgorithm(sentences_per_chunk=2, sentence_overlap=1, embedder=LocalEmbedder()) + docs = ( + "Midnight on August 14-15, 1947, was a landmark moment. " + "Shortly before the stroke of midnight on August 14, India's first prime minister, " + "Jawaharlal Nehru, made a famous speech entitled A Tryst with Destiny." + ) + claim = ( + "India's first prime minister, Jawaharlal Nehru, delivered his renowned " + "Tryst with Destiny speech shortly before midnight on August 14, 1947." + ) + result = algo.compare_to_docs(docs, claim)[0] + assert result["status"] in {"SUPPORTED", "WEAK_SUPPORT"} + assert result["status"] != "HALLUCINATION" + + +def test_semantic_paraphrase_does_not_require_nearby_year(algo): + docs = ( + "Shortly before the stroke of midnight on August 14, India's first prime minister, " + "Jawaharlal Nehru, made a famous speech entitled A Tryst with Destiny." + ) + claim = ( + "India's first prime minister, Jawaharlal Nehru, delivered his renowned " + "Tryst with Destiny speech shortly before midnight on August 14, 1947." + ) + result = algo.compare_to_docs(docs, claim)[0] + assert result["status"] in {"SUPPORTED", "WEAK_SUPPORT"} + + +def test_result_exposes_embedder_diagnostics(): + algo = Halgorithm(embedder=LocalEmbedder()) + result = algo.compare_to_docs("BASIC was created in 1964.", "BASIC was created in 1964.")[0] + assert result["embedder"] == "lexical" + assert result["embedding_model"] == "HashingVectorizer" + + +def test_tui_api_key_prompt_is_visible(monkeypatch, tmp_path): + answers = iter([ + "test-key", + "files", + str(tmp_path / "source.txt"), + "0.30", + "2", + "1", + "What is true?", + ]) + prompt_kwargs = [] + + (tmp_path / "source.txt").write_text("A source.", encoding="utf-8") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + monkeypatch.setattr(tui.console, "clear", lambda: None) + monkeypatch.setattr(tui.Prompt, "ask", lambda *args, **kwargs: prompt_kwargs.append(kwargs) or next(answers)) + + config = tui.collect_inputs() + + assert config["prompt"] == "What is true?" + assert prompt_kwargs[0].get("password") is None diff --git a/tests/test_voting.py b/tests/test_voting.py new file mode 100644 index 0000000..4c6a793 --- /dev/null +++ b/tests/test_voting.py @@ -0,0 +1,74 @@ +import pytest + +from Halgorithem.models import AtomicCheck, AtomicClaimResult, NLICheck, SimilarityCheck +from Halgorithem.voting import fuse_votes + + +def test_voting_supports_strong_entailment(): + verdict, confidence = fuse_votes( + SimilarityCheck(score=0.82, evidence="source"), + NLICheck(verdict="ENTAIL", confidence=0.91, evidence="source"), + AtomicCheck(claims=[AtomicClaimResult(claim="A", verdict="ENTAIL", confidence=0.88)], score=0.88), + ) + assert verdict == "SUPPORTED" + assert confidence >= 0.85 + + +def test_voting_discards_weak_nli(): + verdict, confidence = fuse_votes( + SimilarityCheck(score=0.78, evidence="source"), + NLICheck(verdict="CONTRADICT", confidence=0.40, evidence="source"), + AtomicCheck(claims=[], score=None), + ) + assert verdict == "SUPPORTED" + assert confidence == pytest.approx(0.78) + + +def test_voting_hallucinates_confident_contradiction(): + verdict, confidence = fuse_votes( + SimilarityCheck(score=0.76, evidence="source"), + NLICheck(verdict="CONTRADICT", confidence=0.93, evidence="source"), + AtomicCheck(claims=[], score=None), + ) + assert verdict == "HALLUCINATED" + assert confidence >= 0.93 + + +def test_voting_does_not_let_contested_nli_override_win_alone(): + verdict, confidence = fuse_votes( + SimilarityCheck(score=0.94, evidence="source"), + NLICheck(verdict="CONTRADICT", confidence=0.93, evidence="source"), + AtomicCheck( + claims=[AtomicClaimResult(claim="A", verdict="ENTAIL", confidence=0.92)], + score=0.92, + ), + ) + assert verdict == "UNVERIFIABLE" + assert confidence > 0.9 + + +def test_voting_source_quality_scales_similarity_weight_only(): + trusted_verdict, trusted_confidence = fuse_votes( + SimilarityCheck(score=0.82, evidence="source", source_quality=0.95), + NLICheck(verdict="NEUTRAL", confidence=0.7, evidence="source"), + AtomicCheck(claims=[], score=None), + ) + weak_verdict, weak_confidence = fuse_votes( + SimilarityCheck(score=0.82, evidence="source", source_quality=0.25), + NLICheck(verdict="NEUTRAL", confidence=0.7, evidence="source"), + AtomicCheck(claims=[], score=None), + ) + + assert trusted_verdict == "UNVERIFIABLE" + assert weak_verdict == "UNVERIFIABLE" + assert trusted_confidence < weak_confidence + + +def test_voting_unverifiable_when_only_weak_signals_exist(): + verdict, confidence = fuse_votes( + SimilarityCheck(score=0.22), + NLICheck(verdict="NEUTRAL", confidence=0.52), + AtomicCheck(claims=[], score=None), + ) + assert verdict == "UNVERIFIABLE" + assert confidence == 0.0 diff --git a/tui.py b/tui.py index 2949af1..3d41e58 100644 --- a/tui.py +++ b/tui.py @@ -53,7 +53,7 @@ def collect_inputs(): if existing_key: console.print(f"[dim]Using existing OPENAI_API_KEY ({existing_key[:4]}...)[/dim]") else: - api_key = Prompt.ask("[bold green]OpenAI API key[/bold green]", password=True) + api_key = Prompt.ask("[bold green]OpenAI API key[/bold green]") if not api_key.strip(): console.print("[red]No API key provided. Exiting.[/red]") raise SystemExit(1) @@ -186,6 +186,13 @@ def render_results(source_docs, ai_output, verification, config): config_table.add_row("Sentences/chunk", str(config["sentences_per_chunk"])) config_table.add_row("Overlap", str(config["sentence_overlap"])) config_table.add_row("Sources", str(len(source_docs))) + diagnostics = verification.get("diagnostics") or {} + if diagnostics: + config_table.add_row("Embedder", str(diagnostics.get("embedder", "unknown"))) + if diagnostics.get("embedding_model"): + config_table.add_row("Embedding model", str(diagnostics["embedding_model"])) + if diagnostics.get("embedding_fallback_reason"): + config_table.add_row("Fallback", str(diagnostics["embedding_fallback_reason"])[:80]) console.print(config_table) console.print()