yass4 commited on
Commit
9a3ed32
·
verified ·
1 Parent(s): de4ddac

Publish HALT-CoT

Browse files
.gitignore ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ .mypy_cache/
6
+ .venv/
7
+ venv/
8
+ env/
9
+ build/
10
+ dist/
11
+ *.egg-info/
12
+ .env
13
+ .env.*
14
+ !.env.example
15
+ .DS_Store
16
+ logs/
17
+ outputs/
CITATION.cff ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ cff-version: 1.2.0
2
+ message: "If you use HALT-CoT, please cite the paper."
3
+ title: "HALT-CoT: Model-Agnostic Early Stopping for Chain-of-Thought Reasoning via Answer Entropy"
4
+ authors:
5
+ - family-names: "Laaouach"
6
+ given-names: "Yassir"
7
+ year: 2026
8
+ license: "MIT"
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Yassir Laaouach
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: HALT-CoT
3
+ sdk: gradio
4
+ app_file: app.py
5
+ license: mit
6
+ tags:
7
+ - chain-of-thought
8
+ - reasoning
9
+ - early-stopping
10
+ - entropy
11
+ - transformers
12
+ - inference-optimization
13
+ ---
14
+
15
+ # HALT-CoT
16
+
17
+ HALT-CoT is an inference-time early stopping method for chain-of-thought reasoning. After each generated reasoning step, it computes the Shannon entropy of the model's answer distribution over candidate answers. When entropy stays below a threshold, generation stops and the current most likely answer is returned.
18
+
19
+ This repository packages the method from:
20
+
21
+ **HALT-CoT: Model-Agnostic Early Stopping for Chain-of-Thought Reasoning via Answer Entropy**
22
+ Yassir Laaouach
23
+
24
+ The release is a method implementation and Hugging Face Space, not trained model weights.
25
+
26
+ ## What Is Included
27
+
28
+ - Dependency-free core entropy and halting controller in `halt_cot/core.py`.
29
+ - Hugging Face Transformers backend in `halt_cot/transformers_backend.py`.
30
+ - CLI entrypoint: `halt-cot`.
31
+ - Gradio Space app in `app.py`.
32
+ - Publish helper for `huggingface_hub`.
33
+ - Focused tests for entropy, candidate sets, and halting behavior.
34
+
35
+ ## Install
36
+
37
+ ```bash
38
+ pip install -e ".[transformers]"
39
+ ```
40
+
41
+ For the Gradio Space locally:
42
+
43
+ ```bash
44
+ pip install -e ".[demo]"
45
+ python app.py
46
+ ```
47
+
48
+ ## CLI Example
49
+
50
+ ```bash
51
+ python -m halt_cot.cli \
52
+ --model Qwen/Qwen2.5-0.5B-Instruct \
53
+ --question "If a shop has 12 apples and sells 5, how many apples are left?" \
54
+ --candidates 5 6 7 8 9 \
55
+ --theta 0.6 \
56
+ --consecutive 2
57
+ ```
58
+
59
+ ## Python Example
60
+
61
+ ```python
62
+ from halt_cot import HaltCoTConfig
63
+ from halt_cot.transformers_backend import HaltCoTForCausalLM
64
+
65
+ runner = HaltCoTForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
66
+ config = HaltCoTConfig(theta=0.6, consecutive_low_entropy=2, max_steps=8)
67
+
68
+ result = runner.run(
69
+ "If a shop has 12 apples and sells 5, how many apples are left?",
70
+ candidates=["5", "6", "7", "8", "9"],
71
+ config=config,
72
+ )
73
+
74
+ print(result.answer)
75
+ print(result.reasoning)
76
+ ```
77
+
78
+ ## Method
79
+
80
+ At reasoning step `i`, HALT-CoT scores candidate answers `a in A` from the next-token logits after the current partial chain. It normalizes those candidate scores into `p_i(a)` and computes:
81
+
82
+ ```text
83
+ H_i = - sum_a p_i(a) log p_i(a)
84
+ ```
85
+
86
+ The controller halts when `H_i < theta`, optionally requiring the condition for multiple consecutive steps. This implementation uses bits by default, which aligns with the entropy traces in the paper. If you tune thresholds in nats, set `entropy_unit="nats"`.
87
+
88
+ Suggested starting thresholds from the paper:
89
+
90
+ - Math-style numeric tasks: `theta` in `[0.5, 0.7]`.
91
+ - StrategyQA-style yes/no tasks: `theta = 0.8`.
92
+ - CommonsenseQA-style multiple choice: `theta = 0.7`.
93
+
94
+ For numeric tasks, use task-specific candidate answers where possible. The helper `numeric_candidates_from_texts` can build candidates from training answers plus an integer range.
95
+
96
+ ## Publish To Hugging Face
97
+
98
+ Log in once:
99
+
100
+ ```bash
101
+ python -m huggingface_hub.commands.huggingface_cli login
102
+ ```
103
+
104
+ ### Free Code/Method Repository
105
+
106
+ Hugging Face may require a PRO subscription for hosted Gradio Spaces on `cpu-basic`. To publish the method, code, docs, and tests for free, upload this folder as a normal Hugging Face model repository:
107
+
108
+ ```bash
109
+ python scripts/publish_to_hf.py --repo-id <your-username>/halt-cot --repo-type model
110
+ ```
111
+
112
+ For this account, the repo id is:
113
+
114
+ ```bash
115
+ python scripts/publish_to_hf.py --repo-id yass4/halt-cot --repo-type model
116
+ ```
117
+
118
+ ### Interactive Gradio Space
119
+
120
+ If your account can create Gradio Spaces, upload the same folder as a Space:
121
+
122
+ ```bash
123
+ python scripts/publish_to_hf.py --repo-id <your-username>/halt-cot --repo-type space
124
+ ```
125
+
126
+ The default Space model is controlled by `HALT_COT_MODEL_ID`. Set it in the Space variables to use a different causal LM. Use `HALT_COT_DEVICE_MAP=auto` on hardware that supports Accelerate device placement.
127
+
128
+ ## Citation
129
+
130
+ ```bibtex
131
+ @misc{laaouach2026haltcot,
132
+ title = {HALT-CoT: Model-Agnostic Early Stopping for Chain-of-Thought Reasoning via Answer Entropy},
133
+ author = {Laaouach, Yassir},
134
+ year = {2026}
135
+ }
136
+ ```
app.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face Space app for HALT-CoT."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+
7
+ import gradio as gr
8
+
9
+ from halt_cot import HaltCoTConfig
10
+ from halt_cot.transformers_backend import HaltCoTForCausalLM
11
+
12
+
13
+ DEFAULT_MODEL_ID = os.getenv("HALT_COT_MODEL_ID", "Qwen/Qwen2.5-0.5B-Instruct")
14
+ DEFAULT_DEVICE_MAP = os.getenv("HALT_COT_DEVICE_MAP") or None
15
+ DEFAULT_THETA = float(os.getenv("HALT_COT_THETA", "0.6"))
16
+
17
+ _RUNNER: HaltCoTForCausalLM | None = None
18
+
19
+
20
+ def get_runner() -> HaltCoTForCausalLM:
21
+ global _RUNNER
22
+ if _RUNNER is None:
23
+ _RUNNER = HaltCoTForCausalLM.from_pretrained(
24
+ DEFAULT_MODEL_ID,
25
+ device_map=DEFAULT_DEVICE_MAP,
26
+ )
27
+ return _RUNNER
28
+
29
+
30
+ def parse_candidates(candidate_text: str) -> list[str]:
31
+ lines = []
32
+ for raw_line in candidate_text.replace(",", "\n").splitlines():
33
+ line = raw_line.strip()
34
+ if line:
35
+ lines.append(line)
36
+ if len(lines) < 2:
37
+ raise gr.Error("Provide at least two candidate answers.")
38
+ return lines
39
+
40
+
41
+ def run_halt_cot(
42
+ question: str,
43
+ candidate_text: str,
44
+ theta: float,
45
+ consecutive: int,
46
+ max_steps: int,
47
+ step_max_new_tokens: int,
48
+ ):
49
+ if not question.strip():
50
+ raise gr.Error("Question is required.")
51
+
52
+ config = HaltCoTConfig(
53
+ theta=float(theta),
54
+ consecutive_low_entropy=int(consecutive),
55
+ max_steps=int(max_steps),
56
+ step_max_new_tokens=int(step_max_new_tokens),
57
+ )
58
+ result = get_runner().run(question.strip(), parse_candidates(candidate_text), config=config)
59
+ trace_rows = [
60
+ [
61
+ step.index,
62
+ round(step.entropy, 4),
63
+ step.prediction,
64
+ step.generated_tokens,
65
+ step.halted,
66
+ step.text,
67
+ ]
68
+ for step in result.steps
69
+ ]
70
+ final_probs = []
71
+ if result.steps:
72
+ final_probs = sorted(
73
+ result.steps[-1].probabilities.items(),
74
+ key=lambda item: item[1],
75
+ reverse=True,
76
+ )
77
+ final_probs = [[label, round(probability, 6)] for label, probability in final_probs]
78
+
79
+ status = "halted" if result.halted else "reached max steps"
80
+ summary = (
81
+ f"Answer: {result.answer}\n"
82
+ f"Status: {status}\n"
83
+ f"Generated reasoning tokens: {result.generated_tokens}"
84
+ )
85
+ return summary, result.reasoning, trace_rows, final_probs
86
+
87
+
88
+ with gr.Blocks(title="HALT-CoT") as demo:
89
+ gr.Markdown("# HALT-CoT")
90
+ with gr.Row():
91
+ with gr.Column(scale=2):
92
+ question = gr.Textbox(
93
+ label="Question",
94
+ lines=5,
95
+ value="If a shop has 12 apples and sells 5, how many apples are left?",
96
+ )
97
+ candidates = gr.Textbox(
98
+ label="Candidate answers",
99
+ lines=6,
100
+ value="5\n6\n7\n8\n9",
101
+ )
102
+ run_button = gr.Button("Run HALT-CoT", variant="primary")
103
+ with gr.Column(scale=1):
104
+ theta = gr.Slider(0.0, 2.0, value=DEFAULT_THETA, step=0.05, label="Entropy threshold")
105
+ consecutive = gr.Slider(1, 4, value=2, step=1, label="Consecutive low-entropy steps")
106
+ max_steps = gr.Slider(1, 12, value=8, step=1, label="Maximum steps")
107
+ step_max_new_tokens = gr.Slider(8, 160, value=64, step=8, label="Step token cap")
108
+
109
+ summary = gr.Textbox(label="Result", lines=3)
110
+ reasoning = gr.Textbox(label="Generated reasoning", lines=8)
111
+ trace = gr.Dataframe(
112
+ headers=["Step", "Entropy", "Prediction", "Tokens", "Halted", "Text"],
113
+ label="HALT trace",
114
+ )
115
+ probabilities = gr.Dataframe(headers=["Candidate", "Probability"], label="Final answer distribution")
116
+
117
+ run_button.click(
118
+ run_halt_cot,
119
+ inputs=[question, candidates, theta, consecutive, max_steps, step_max_new_tokens],
120
+ outputs=[summary, reasoning, trace, probabilities],
121
+ )
122
+
123
+
124
+ if __name__ == "__main__":
125
+ demo.launch()
examples/run_halt_cot.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal HALT-CoT example using a Hugging Face causal LM."""
2
+
3
+ from halt_cot import HaltCoTConfig
4
+ from halt_cot.transformers_backend import HaltCoTForCausalLM
5
+
6
+
7
+ def main() -> None:
8
+ runner = HaltCoTForCausalLM.from_pretrained("Qwen/Qwen2.5-0.5B-Instruct")
9
+ result = runner.run(
10
+ "If a shop has 12 apples and sells 5, how many apples are left?",
11
+ candidates=["5", "6", "7", "8", "9"],
12
+ config=HaltCoTConfig(theta=0.6, consecutive_low_entropy=2, max_steps=8),
13
+ )
14
+ print(result.answer)
15
+ print(result.reasoning)
16
+
17
+
18
+ if __name__ == "__main__":
19
+ main()
halt_cot/__init__.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """HALT-CoT: entropy-based early stopping for chain-of-thought reasoning."""
2
+
3
+ from .core import (
4
+ AnswerCandidate,
5
+ AnswerDistribution,
6
+ EntropyHaltingController,
7
+ HaltCoTConfig,
8
+ HaltCoTResult,
9
+ HaltCoTStep,
10
+ HaltDecision,
11
+ answer_distribution_from_scores,
12
+ entropy_from_probabilities,
13
+ format_reasoning,
14
+ integer_candidates,
15
+ multiple_choice_candidates,
16
+ normalize_candidates,
17
+ numeric_candidates_from_texts,
18
+ softmax,
19
+ yes_no_candidates,
20
+ )
21
+
22
+ __all__ = [
23
+ "AnswerCandidate",
24
+ "AnswerDistribution",
25
+ "EntropyHaltingController",
26
+ "HaltCoTConfig",
27
+ "HaltCoTResult",
28
+ "HaltCoTStep",
29
+ "HaltDecision",
30
+ "answer_distribution_from_scores",
31
+ "entropy_from_probabilities",
32
+ "format_reasoning",
33
+ "integer_candidates",
34
+ "multiple_choice_candidates",
35
+ "normalize_candidates",
36
+ "numeric_candidates_from_texts",
37
+ "softmax",
38
+ "yes_no_candidates",
39
+ ]
halt_cot/cli.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Command line entrypoint for HALT-CoT."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+
8
+ from .core import (
9
+ HaltCoTConfig,
10
+ integer_candidates,
11
+ multiple_choice_candidates,
12
+ yes_no_candidates,
13
+ )
14
+ from .transformers_backend import HaltCoTForCausalLM
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ parser = argparse.ArgumentParser(description="Run HALT-CoT with a Hugging Face causal LM.")
19
+ parser.add_argument("--model", required=True, help="Hugging Face model id or local model path.")
20
+ parser.add_argument("--question", required=True, help="Question to answer.")
21
+ parser.add_argument(
22
+ "--candidate-set",
23
+ choices=("custom", "yes-no", "multiple-choice", "integers"),
24
+ default="custom",
25
+ help="Candidate answer set.",
26
+ )
27
+ parser.add_argument(
28
+ "--candidates",
29
+ nargs="*",
30
+ default=None,
31
+ help="Custom candidate labels, e.g. --candidates Yes No or --candidates A B C D E.",
32
+ )
33
+ parser.add_argument("--integer-start", type=int, default=0)
34
+ parser.add_argument("--integer-end", type=int, default=100)
35
+ parser.add_argument("--theta", type=float, default=0.6)
36
+ parser.add_argument("--max-steps", type=int, default=12)
37
+ parser.add_argument("--min-steps", type=int, default=1)
38
+ parser.add_argument("--consecutive", type=int, default=2)
39
+ parser.add_argument("--step-max-new-tokens", type=int, default=96)
40
+ parser.add_argument("--entropy-unit", choices=("bits", "nats"), default="bits")
41
+ parser.add_argument("--device-map", default=None, help="Optional Transformers device_map, e.g. auto.")
42
+ parser.add_argument("--trust-remote-code", action="store_true")
43
+ parser.add_argument("--json", action="store_true", help="Emit machine-readable JSON.")
44
+ return parser
45
+
46
+
47
+ def main(argv: list[str] | None = None) -> int:
48
+ args = build_parser().parse_args(argv)
49
+ candidates = _resolve_candidates(args)
50
+ config = HaltCoTConfig(
51
+ theta=args.theta,
52
+ max_steps=args.max_steps,
53
+ min_steps=args.min_steps,
54
+ consecutive_low_entropy=args.consecutive,
55
+ step_max_new_tokens=args.step_max_new_tokens,
56
+ entropy_unit=args.entropy_unit,
57
+ )
58
+
59
+ runner = HaltCoTForCausalLM.from_pretrained(
60
+ args.model,
61
+ config=config,
62
+ device_map=args.device_map,
63
+ trust_remote_code=args.trust_remote_code,
64
+ )
65
+ result = runner.run(args.question, candidates)
66
+
67
+ if args.json:
68
+ print(
69
+ json.dumps(
70
+ {
71
+ "answer": result.answer,
72
+ "halted": result.halted,
73
+ "generated_tokens": result.generated_tokens,
74
+ "steps": [
75
+ {
76
+ "index": step.index,
77
+ "text": step.text,
78
+ "entropy": step.entropy,
79
+ "prediction": step.prediction,
80
+ "probabilities": step.probabilities,
81
+ "halted": step.halted,
82
+ "generated_tokens": step.generated_tokens,
83
+ }
84
+ for step in result.steps
85
+ ],
86
+ },
87
+ indent=2,
88
+ )
89
+ )
90
+ else:
91
+ print(f"Answer: {result.answer}")
92
+ print(f"Halted: {result.halted}")
93
+ print(f"Generated tokens: {result.generated_tokens}")
94
+ print("\nTrace:")
95
+ for step in result.steps:
96
+ marker = " HALT" if step.halted else ""
97
+ print(
98
+ f"- Step {step.index}: H={step.entropy:.3f} "
99
+ f"pred={step.prediction!r}{marker} | {step.text}"
100
+ )
101
+ return 0
102
+
103
+
104
+ def _resolve_candidates(args: argparse.Namespace):
105
+ if args.candidate_set == "yes-no":
106
+ return yes_no_candidates()
107
+ if args.candidate_set == "multiple-choice":
108
+ labels = args.candidates or ["A", "B", "C", "D", "E"]
109
+ return multiple_choice_candidates(labels)
110
+ if args.candidate_set == "integers":
111
+ return integer_candidates(args.integer_start, args.integer_end)
112
+ if not args.candidates:
113
+ raise SystemExit("--candidates is required when --candidate-set custom")
114
+ return args.candidates
115
+
116
+
117
+ if __name__ == "__main__":
118
+ raise SystemExit(main())
halt_cot/core.py ADDED
@@ -0,0 +1,294 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core HALT-CoT primitives.
2
+
3
+ The functions in this module are dependency-free so they can be used in API
4
+ integrations, tests, and backends that are not based on Hugging Face
5
+ Transformers.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ import math
12
+ import re
13
+ from typing import Iterable, Mapping, Sequence
14
+
15
+
16
+ EntropyUnit = str
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class AnswerCandidate:
21
+ """A possible final answer and optional textual aliases for token scoring."""
22
+
23
+ label: str
24
+ aliases: tuple[str, ...] = ()
25
+
26
+ def __post_init__(self) -> None:
27
+ label = self.label.strip()
28
+ if not label:
29
+ raise ValueError("AnswerCandidate.label must not be empty")
30
+ object.__setattr__(self, "label", label)
31
+ object.__setattr__(
32
+ self,
33
+ "aliases",
34
+ tuple(alias.strip() for alias in self.aliases if alias.strip()),
35
+ )
36
+
37
+ @property
38
+ def texts(self) -> tuple[str, ...]:
39
+ return (self.label, *self.aliases)
40
+
41
+
42
+ @dataclass(frozen=True)
43
+ class AnswerDistribution:
44
+ """Normalized answer probabilities and their entropy."""
45
+
46
+ probabilities: dict[str, float]
47
+ entropy: float
48
+ prediction: str
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class HaltCoTConfig:
53
+ """Configuration for entropy-based chain-of-thought halting."""
54
+
55
+ theta: float = 0.6
56
+ max_steps: int = 12
57
+ min_steps: int = 1
58
+ consecutive_low_entropy: int = 2
59
+ entropy_unit: EntropyUnit = "bits"
60
+ step_max_new_tokens: int = 96
61
+ step_min_new_tokens: int = 4
62
+ prompt_template: str = "{question}\n\nLet's think step by step.\n"
63
+ step_prefix: str = "Step {step}: "
64
+ step_stop_strings: tuple[str, ...] = ("\nStep", "\nAnswer:", "\nFinal answer:", "\n")
65
+ answer_probe: str = "\nTherefore, the final answer is "
66
+ do_sample: bool = False
67
+ temperature: float = 0.0
68
+ top_p: float = 1.0
69
+
70
+ def __post_init__(self) -> None:
71
+ if self.theta < 0:
72
+ raise ValueError("theta must be non-negative")
73
+ if self.max_steps < 1:
74
+ raise ValueError("max_steps must be at least 1")
75
+ if self.min_steps < 1:
76
+ raise ValueError("min_steps must be at least 1")
77
+ if self.min_steps > self.max_steps:
78
+ raise ValueError("min_steps cannot exceed max_steps")
79
+ if self.consecutive_low_entropy < 1:
80
+ raise ValueError("consecutive_low_entropy must be at least 1")
81
+ if self.entropy_unit not in {"bits", "nats"}:
82
+ raise ValueError("entropy_unit must be 'bits' or 'nats'")
83
+ if self.step_max_new_tokens < 1:
84
+ raise ValueError("step_max_new_tokens must be at least 1")
85
+ if self.step_min_new_tokens < 0:
86
+ raise ValueError("step_min_new_tokens must be non-negative")
87
+ if self.temperature < 0:
88
+ raise ValueError("temperature must be non-negative")
89
+ if not 0 < self.top_p <= 1:
90
+ raise ValueError("top_p must be in (0, 1]")
91
+
92
+
93
+ @dataclass(frozen=True)
94
+ class HaltDecision:
95
+ """Controller state after observing one entropy value."""
96
+
97
+ should_halt: bool
98
+ low_entropy_streak: int
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class HaltCoTStep:
103
+ """One generated reasoning step plus HALT-CoT diagnostics."""
104
+
105
+ index: int
106
+ text: str
107
+ entropy: float
108
+ prediction: str
109
+ probabilities: dict[str, float]
110
+ halted: bool
111
+ generated_tokens: int = 0
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class HaltCoTResult:
116
+ """Final output of a HALT-CoT run."""
117
+
118
+ answer: str
119
+ halted: bool
120
+ steps: tuple[HaltCoTStep, ...]
121
+ reasoning: str
122
+ generated_tokens: int
123
+
124
+
125
+ class EntropyHaltingController:
126
+ """Implements the threshold and consecutive-step guard from the paper."""
127
+
128
+ def __init__(self, config: HaltCoTConfig):
129
+ self.config = config
130
+ self.low_entropy_streak = 0
131
+
132
+ def observe(self, entropy: float, step_index: int) -> HaltDecision:
133
+ if step_index < self.config.min_steps:
134
+ self.low_entropy_streak = 0
135
+ return HaltDecision(False, self.low_entropy_streak)
136
+
137
+ if entropy < self.config.theta:
138
+ self.low_entropy_streak += 1
139
+ else:
140
+ self.low_entropy_streak = 0
141
+
142
+ return HaltDecision(
143
+ should_halt=self.low_entropy_streak >= self.config.consecutive_low_entropy,
144
+ low_entropy_streak=self.low_entropy_streak,
145
+ )
146
+
147
+
148
+ def normalize_candidates(
149
+ candidates: Sequence[str | AnswerCandidate],
150
+ ) -> tuple[AnswerCandidate, ...]:
151
+ """Convert user-provided strings or candidates into validated candidates."""
152
+
153
+ normalized: list[AnswerCandidate] = []
154
+ seen: set[str] = set()
155
+ for candidate in candidates:
156
+ item = candidate if isinstance(candidate, AnswerCandidate) else AnswerCandidate(str(candidate))
157
+ key = item.label.casefold()
158
+ if key in seen:
159
+ raise ValueError(f"Duplicate answer candidate: {item.label!r}")
160
+ seen.add(key)
161
+ normalized.append(item)
162
+
163
+ if len(normalized) < 2:
164
+ raise ValueError("At least two answer candidates are required")
165
+ return tuple(normalized)
166
+
167
+
168
+ def softmax(scores: Mapping[str, float]) -> dict[str, float]:
169
+ """Stable softmax over a label-to-score mapping."""
170
+
171
+ if not scores:
172
+ raise ValueError("scores must not be empty")
173
+
174
+ max_score = max(scores.values())
175
+ weights = {label: math.exp(score - max_score) for label, score in scores.items()}
176
+ total = sum(weights.values())
177
+ if total == 0 or not math.isfinite(total):
178
+ raise ValueError("scores produced an invalid softmax normalization")
179
+ return {label: weight / total for label, weight in weights.items()}
180
+
181
+
182
+ def entropy_from_probabilities(
183
+ probabilities: Mapping[str, float],
184
+ entropy_unit: EntropyUnit = "bits",
185
+ ) -> float:
186
+ """Compute Shannon entropy for an answer distribution."""
187
+
188
+ if entropy_unit not in {"bits", "nats"}:
189
+ raise ValueError("entropy_unit must be 'bits' or 'nats'")
190
+ if not probabilities:
191
+ raise ValueError("probabilities must not be empty")
192
+
193
+ total = sum(probabilities.values())
194
+ if not math.isclose(total, 1.0, rel_tol=1e-6, abs_tol=1e-6):
195
+ raise ValueError(f"probabilities must sum to 1, got {total}")
196
+
197
+ log = math.log2 if entropy_unit == "bits" else math.log
198
+ return -sum(p * log(p) for p in probabilities.values() if p > 0)
199
+
200
+
201
+ def answer_distribution_from_scores(
202
+ scores: Mapping[str, float],
203
+ entropy_unit: EntropyUnit = "bits",
204
+ ) -> AnswerDistribution:
205
+ """Turn answer logits/scores into probabilities, entropy, and argmax answer."""
206
+
207
+ probabilities = softmax(scores)
208
+ entropy = entropy_from_probabilities(probabilities, entropy_unit)
209
+ prediction = max(probabilities, key=probabilities.__getitem__)
210
+ return AnswerDistribution(
211
+ probabilities=dict(probabilities),
212
+ entropy=entropy,
213
+ prediction=prediction,
214
+ )
215
+
216
+
217
+ def yes_no_candidates() -> tuple[AnswerCandidate, AnswerCandidate]:
218
+ """Standard StrategyQA-style answer set."""
219
+
220
+ return (
221
+ AnswerCandidate("Yes", aliases=("yes", "YES")),
222
+ AnswerCandidate("No", aliases=("no", "NO")),
223
+ )
224
+
225
+
226
+ def multiple_choice_candidates(
227
+ labels: Iterable[str] = ("A", "B", "C", "D", "E"),
228
+ ) -> tuple[AnswerCandidate, ...]:
229
+ """Create option candidates with common first-token answer spellings."""
230
+
231
+ candidates = []
232
+ for raw_label in labels:
233
+ label = raw_label.strip()
234
+ if not label:
235
+ continue
236
+ candidates.append(
237
+ AnswerCandidate(
238
+ label,
239
+ aliases=(
240
+ label.lower(),
241
+ f"({label})",
242
+ f"{label}.",
243
+ f"{label})",
244
+ ),
245
+ )
246
+ )
247
+ return normalize_candidates(candidates)
248
+
249
+
250
+ def integer_candidates(start: int = 0, end: int = 100) -> tuple[AnswerCandidate, ...]:
251
+ """Numeric answer candidates for math-style tasks."""
252
+
253
+ if start > end:
254
+ raise ValueError("start cannot exceed end")
255
+ return tuple(AnswerCandidate(str(value)) for value in range(start, end + 1))
256
+
257
+
258
+ _NUMBER_RE = re.compile(r"[-+]?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?")
259
+
260
+
261
+ def numeric_candidates_from_texts(
262
+ texts: Iterable[str],
263
+ extra_integers: tuple[int, int] | None = (0, 100),
264
+ max_candidates: int | None = None,
265
+ ) -> tuple[AnswerCandidate, ...]:
266
+ """Build a numeric candidate set from answer strings plus an integer range."""
267
+
268
+ labels: dict[str, None] = {}
269
+ for text in texts:
270
+ for match in _NUMBER_RE.findall(text):
271
+ labels[match.replace(",", "")] = None
272
+
273
+ if extra_integers is not None:
274
+ start, end = extra_integers
275
+ for value in range(start, end + 1):
276
+ labels[str(value)] = None
277
+
278
+ sorted_labels = sorted(labels, key=_numeric_sort_key)
279
+ if max_candidates is not None:
280
+ sorted_labels = sorted_labels[:max_candidates]
281
+ return normalize_candidates([AnswerCandidate(label) for label in sorted_labels])
282
+
283
+
284
+ def _numeric_sort_key(label: str) -> tuple[int, float | str]:
285
+ try:
286
+ return (0, float(label))
287
+ except ValueError:
288
+ return (1, label)
289
+
290
+
291
+ def format_reasoning(steps: Sequence[HaltCoTStep]) -> str:
292
+ """Render generated steps for logs, demos, and model cards."""
293
+
294
+ return "\n".join(f"Step {step.index}: {step.text}".rstrip() for step in steps)
halt_cot/transformers_backend.py ADDED
@@ -0,0 +1,291 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hugging Face Transformers backend for HALT-CoT."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import replace
6
+ import math
7
+ from typing import Sequence
8
+
9
+ from .core import (
10
+ AnswerCandidate,
11
+ AnswerDistribution,
12
+ EntropyHaltingController,
13
+ HaltCoTConfig,
14
+ HaltCoTResult,
15
+ HaltCoTStep,
16
+ answer_distribution_from_scores,
17
+ format_reasoning,
18
+ normalize_candidates,
19
+ )
20
+
21
+
22
+ class TextStopCriteria:
23
+ """Transformers stopping criterion that halts when generated text hits a marker."""
24
+
25
+ def __init__(
26
+ self,
27
+ tokenizer,
28
+ prompt_length: int,
29
+ stop_strings: Sequence[str],
30
+ min_new_tokens: int,
31
+ ):
32
+ from transformers import StoppingCriteria
33
+
34
+ class _Criterion(StoppingCriteria):
35
+ def __call__(inner_self, input_ids, scores, **kwargs) -> bool:
36
+ new_len = input_ids.shape[-1] - prompt_length
37
+ if new_len < min_new_tokens:
38
+ return False
39
+ tail_start = max(prompt_length, input_ids.shape[-1] - 64)
40
+ tail = tokenizer.decode(
41
+ input_ids[0, tail_start:],
42
+ skip_special_tokens=False,
43
+ )
44
+ return any(stop in tail for stop in stop_strings)
45
+
46
+ self.criterion = _Criterion()
47
+
48
+
49
+ class HaltCoTForCausalLM:
50
+ """Run HALT-CoT with any causal language model exposing next-token logits."""
51
+
52
+ def __init__(self, model, tokenizer, config: HaltCoTConfig | None = None):
53
+ self.model = model
54
+ self.tokenizer = tokenizer
55
+ self.config = config or HaltCoTConfig()
56
+
57
+ if getattr(self.tokenizer, "pad_token_id", None) is None:
58
+ eos_token = getattr(self.tokenizer, "eos_token", None)
59
+ if eos_token is not None:
60
+ self.tokenizer.pad_token = eos_token
61
+
62
+ @classmethod
63
+ def from_pretrained(
64
+ cls,
65
+ model_id: str,
66
+ *,
67
+ config: HaltCoTConfig | None = None,
68
+ torch_dtype: str | None = "auto",
69
+ device_map: str | None = None,
70
+ trust_remote_code: bool = False,
71
+ **kwargs,
72
+ ) -> "HaltCoTForCausalLM":
73
+ """Load a Transformers causal LM and tokenizer."""
74
+
75
+ from transformers import AutoModelForCausalLM, AutoTokenizer
76
+
77
+ tokenizer = AutoTokenizer.from_pretrained(
78
+ model_id,
79
+ trust_remote_code=trust_remote_code,
80
+ )
81
+
82
+ model_kwargs = dict(kwargs)
83
+ if torch_dtype is not None:
84
+ model_kwargs["torch_dtype"] = torch_dtype
85
+ if device_map is not None:
86
+ model_kwargs["device_map"] = device_map
87
+ model = AutoModelForCausalLM.from_pretrained(
88
+ model_id,
89
+ trust_remote_code=trust_remote_code,
90
+ **model_kwargs,
91
+ )
92
+ if device_map is None:
93
+ model.to(_default_device())
94
+ model.eval()
95
+ return cls(model=model, tokenizer=tokenizer, config=config)
96
+
97
+ def run(
98
+ self,
99
+ question: str,
100
+ candidates: Sequence[str | AnswerCandidate],
101
+ config: HaltCoTConfig | None = None,
102
+ ) -> HaltCoTResult:
103
+ """Generate reasoning steps until answer entropy crosses the threshold."""
104
+
105
+ run_config = config or self.config
106
+ answer_candidates = normalize_candidates(candidates)
107
+ controller = EntropyHaltingController(run_config)
108
+ context = run_config.prompt_template.format(question=question)
109
+ steps: list[HaltCoTStep] = []
110
+ generated_tokens = 0
111
+ last_distribution: AnswerDistribution | None = None
112
+
113
+ for index in range(1, run_config.max_steps + 1):
114
+ step_prompt = _ensure_trailing_newline(context) + run_config.step_prefix.format(step=index)
115
+ step_text, token_count = self.generate_step(step_prompt, run_config)
116
+ generated_tokens += token_count
117
+ step_text = step_text.strip()
118
+ context = f"{step_prompt}{step_text}\n"
119
+
120
+ distribution = self.answer_distribution(
121
+ context + run_config.answer_probe,
122
+ answer_candidates,
123
+ entropy_unit=run_config.entropy_unit,
124
+ )
125
+ last_distribution = distribution
126
+ decision = controller.observe(distribution.entropy, index)
127
+
128
+ steps.append(
129
+ HaltCoTStep(
130
+ index=index,
131
+ text=step_text,
132
+ entropy=distribution.entropy,
133
+ prediction=distribution.prediction,
134
+ probabilities=distribution.probabilities,
135
+ halted=decision.should_halt,
136
+ generated_tokens=token_count,
137
+ )
138
+ )
139
+
140
+ if decision.should_halt:
141
+ return HaltCoTResult(
142
+ answer=distribution.prediction,
143
+ halted=True,
144
+ steps=tuple(steps),
145
+ reasoning=format_reasoning(steps),
146
+ generated_tokens=generated_tokens,
147
+ )
148
+
149
+ if last_distribution is None:
150
+ raise RuntimeError("HALT-CoT run produced no steps")
151
+
152
+ return HaltCoTResult(
153
+ answer=last_distribution.prediction,
154
+ halted=False,
155
+ steps=tuple(steps),
156
+ reasoning=format_reasoning(steps),
157
+ generated_tokens=generated_tokens,
158
+ )
159
+
160
+ def generate_step(self, prompt: str, config: HaltCoTConfig) -> tuple[str, int]:
161
+ """Generate one bounded reasoning step."""
162
+
163
+ import torch
164
+ from transformers import StoppingCriteriaList
165
+
166
+ inputs = self._encode(prompt)
167
+ prompt_length = inputs["input_ids"].shape[-1]
168
+ stopping_criteria = None
169
+ if config.step_stop_strings:
170
+ stopping_criteria = StoppingCriteriaList(
171
+ [
172
+ TextStopCriteria(
173
+ self.tokenizer,
174
+ prompt_length,
175
+ config.step_stop_strings,
176
+ config.step_min_new_tokens,
177
+ ).criterion
178
+ ]
179
+ )
180
+
181
+ generation_kwargs = {
182
+ "max_new_tokens": config.step_max_new_tokens,
183
+ "do_sample": config.do_sample,
184
+ "pad_token_id": self.tokenizer.pad_token_id,
185
+ "eos_token_id": self.tokenizer.eos_token_id,
186
+ "stopping_criteria": stopping_criteria,
187
+ }
188
+ if config.do_sample:
189
+ generation_kwargs["temperature"] = max(config.temperature, 1e-6)
190
+ generation_kwargs["top_p"] = config.top_p
191
+
192
+ with torch.inference_mode():
193
+ output_ids = self.model.generate(**inputs, **generation_kwargs)[0]
194
+
195
+ new_ids = output_ids[prompt_length:]
196
+ text = self.tokenizer.decode(new_ids, skip_special_tokens=True).lstrip()
197
+ text = _truncate_at_first_stop(text, config.step_stop_strings)
198
+ return text, int(new_ids.shape[-1])
199
+
200
+ def answer_distribution(
201
+ self,
202
+ context: str,
203
+ candidates: Sequence[AnswerCandidate],
204
+ *,
205
+ entropy_unit: str = "bits",
206
+ ) -> AnswerDistribution:
207
+ """Compute answer entropy from next-token logits over candidates."""
208
+
209
+ import torch
210
+
211
+ candidate_token_ids = first_token_ids_by_candidate(self.tokenizer, candidates)
212
+ inputs = self._encode(context)
213
+ with torch.inference_mode():
214
+ logits = self.model(**inputs).logits[0, -1, :].float()
215
+
216
+ scores: dict[str, float] = {}
217
+ for candidate in candidates:
218
+ token_ids = candidate_token_ids[candidate.label]
219
+ token_logits = logits[token_ids]
220
+ scores[candidate.label] = float(torch.logsumexp(token_logits, dim=0).item())
221
+
222
+ return answer_distribution_from_scores(scores, entropy_unit=entropy_unit)
223
+
224
+ def _encode(self, text: str):
225
+ inputs = self.tokenizer(text, return_tensors="pt")
226
+ return inputs.to(_model_device(self.model))
227
+
228
+
229
+ def first_token_ids_by_candidate(
230
+ tokenizer,
231
+ candidates: Sequence[AnswerCandidate],
232
+ ) -> dict[str, list[int]]:
233
+ """Map each candidate to first-token ids for its label and aliases."""
234
+
235
+ mapping: dict[str, list[int]] = {}
236
+ for candidate in candidates:
237
+ token_ids: set[int] = set()
238
+ for text in candidate.texts:
239
+ for variant in _candidate_variants(text):
240
+ encoded = tokenizer.encode(variant, add_special_tokens=False)
241
+ if encoded:
242
+ token_ids.add(int(encoded[0]))
243
+ if not token_ids:
244
+ raise ValueError(f"Candidate {candidate.label!r} did not tokenize to any ids")
245
+ mapping[candidate.label] = sorted(token_ids)
246
+ return mapping
247
+
248
+
249
+ def with_config(base: HaltCoTConfig, **updates) -> HaltCoTConfig:
250
+ """Return a modified config while preserving dataclass validation."""
251
+
252
+ return replace(base, **updates)
253
+
254
+
255
+ def _candidate_variants(text: str) -> tuple[str, ...]:
256
+ stripped = text.strip()
257
+ variants = {stripped, f" {stripped}"}
258
+ if stripped[:1].isalpha():
259
+ variants.add(stripped.lower())
260
+ variants.add(stripped.upper())
261
+ variants.add(f" {stripped.lower()}")
262
+ variants.add(f" {stripped.upper()}")
263
+ return tuple(variant for variant in variants if variant)
264
+
265
+
266
+ def _truncate_at_first_stop(text: str, stop_strings: Sequence[str]) -> str:
267
+ if not stop_strings:
268
+ return text
269
+ indexes = [text.find(stop) for stop in stop_strings if text.find(stop) >= 0]
270
+ if not indexes:
271
+ return text
272
+ return text[: min(indexes)]
273
+
274
+
275
+ def _ensure_trailing_newline(text: str) -> str:
276
+ return text if text.endswith("\n") else f"{text}\n"
277
+
278
+
279
+ def _model_device(model):
280
+ try:
281
+ return next(model.parameters()).device
282
+ except StopIteration:
283
+ return _default_device()
284
+
285
+
286
+ def _default_device():
287
+ import torch
288
+
289
+ if torch.cuda.is_available():
290
+ return torch.device("cuda")
291
+ return torch.device("cpu")
pyproject.toml ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=68"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "halt-cot"
7
+ version = "0.1.0"
8
+ description = "Entropy-based early stopping for chain-of-thought reasoning."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = { text = "MIT License" }
12
+ authors = [{ name = "Yassir Laaouach" }]
13
+ keywords = ["chain-of-thought", "entropy", "early-stopping", "reasoning", "transformers"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Science/Research",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Programming Language :: Python :: 3.10",
20
+ "Programming Language :: Python :: 3.11",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.optional-dependencies]
27
+ transformers = [
28
+ "torch>=2.2",
29
+ "transformers>=4.44",
30
+ "accelerate>=0.33",
31
+ ]
32
+ demo = [
33
+ "torch>=2.2",
34
+ "transformers>=4.44",
35
+ "accelerate>=0.33",
36
+ "gradio>=4.44",
37
+ "huggingface_hub>=0.24",
38
+ ]
39
+ publish = [
40
+ "huggingface_hub>=0.24",
41
+ ]
42
+ dev = [
43
+ "pytest>=8",
44
+ ]
45
+
46
+ [project.scripts]
47
+ halt-cot = "halt_cot.cli:main"
48
+
49
+ [tool.setuptools.packages.find]
50
+ include = ["halt_cot*"]
51
+
52
+ [tool.pytest.ini_options]
53
+ testpaths = ["tests"]
54
+ addopts = "-q"
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ torch>=2.2
2
+ transformers>=4.44
3
+ accelerate>=0.33
4
+ gradio>=4.44
5
+ huggingface_hub>=0.24
scripts/publish_to_hf.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Upload the current HALT-CoT folder to Hugging Face."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ from pathlib import Path
7
+
8
+ from huggingface_hub import create_repo, upload_folder
9
+
10
+
11
+ DEFAULT_IGNORE = [
12
+ ".git",
13
+ ".git/*",
14
+ ".pytest_cache",
15
+ ".pytest_cache/*",
16
+ "__pycache__",
17
+ "**/__pycache__/*",
18
+ "*.pyc",
19
+ "*.egg-info",
20
+ "*.egg-info/*",
21
+ "build",
22
+ "build/*",
23
+ "dist",
24
+ "dist/*",
25
+ ".env",
26
+ ".env.*",
27
+ ]
28
+
29
+
30
+ def build_parser() -> argparse.ArgumentParser:
31
+ parser = argparse.ArgumentParser(description="Publish HALT-CoT to Hugging Face.")
32
+ parser.add_argument("--repo-id", required=True, help="Example: username/halt-cot")
33
+ parser.add_argument("--repo-type", choices=("space", "model", "dataset"), default="space")
34
+ parser.add_argument("--private", action="store_true")
35
+ parser.add_argument("--commit-message", default="Publish HALT-CoT")
36
+ return parser
37
+
38
+
39
+ def main(argv: list[str] | None = None) -> int:
40
+ args = build_parser().parse_args(argv)
41
+ project_root = Path(__file__).resolve().parents[1]
42
+
43
+ create_repo(
44
+ repo_id=args.repo_id,
45
+ repo_type=args.repo_type,
46
+ private=args.private,
47
+ exist_ok=True,
48
+ space_sdk="gradio" if args.repo_type == "space" else None,
49
+ )
50
+ upload_folder(
51
+ repo_id=args.repo_id,
52
+ repo_type=args.repo_type,
53
+ folder_path=str(project_root),
54
+ path_in_repo=".",
55
+ ignore_patterns=DEFAULT_IGNORE,
56
+ commit_message=args.commit_message,
57
+ )
58
+ print(f"Uploaded {project_root} to {args.repo_type} repo {args.repo_id}")
59
+ return 0
60
+
61
+
62
+ if __name__ == "__main__":
63
+ raise SystemExit(main())
tests/test_core.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import math
2
+
3
+ import pytest
4
+
5
+ from halt_cot import (
6
+ AnswerCandidate,
7
+ EntropyHaltingController,
8
+ HaltCoTConfig,
9
+ answer_distribution_from_scores,
10
+ entropy_from_probabilities,
11
+ integer_candidates,
12
+ multiple_choice_candidates,
13
+ normalize_candidates,
14
+ numeric_candidates_from_texts,
15
+ yes_no_candidates,
16
+ )
17
+
18
+
19
+ def test_uniform_binary_entropy_is_one_bit():
20
+ entropy = entropy_from_probabilities({"Yes": 0.5, "No": 0.5}, entropy_unit="bits")
21
+ assert entropy == pytest.approx(1.0)
22
+
23
+
24
+ def test_answer_distribution_uses_softmax_and_argmax():
25
+ distribution = answer_distribution_from_scores({"A": 4.0, "B": 0.0}, entropy_unit="bits")
26
+ assert distribution.prediction == "A"
27
+ assert distribution.probabilities["A"] > 0.98
28
+ assert 0.0 < distribution.entropy < 0.2
29
+
30
+
31
+ def test_entropy_validates_probability_mass():
32
+ with pytest.raises(ValueError, match="sum to 1"):
33
+ entropy_from_probabilities({"A": 0.2, "B": 0.2})
34
+
35
+
36
+ def test_halting_controller_requires_consecutive_low_entropy_steps():
37
+ controller = EntropyHaltingController(
38
+ HaltCoTConfig(theta=0.5, consecutive_low_entropy=2, max_steps=4)
39
+ )
40
+ first = controller.observe(entropy=0.4, step_index=1)
41
+ second = controller.observe(entropy=0.3, step_index=2)
42
+ assert not first.should_halt
43
+ assert second.should_halt
44
+ assert second.low_entropy_streak == 2
45
+
46
+
47
+ def test_halting_controller_respects_min_steps():
48
+ controller = EntropyHaltingController(
49
+ HaltCoTConfig(theta=0.5, min_steps=3, consecutive_low_entropy=1, max_steps=4)
50
+ )
51
+ assert not controller.observe(entropy=0.1, step_index=1).should_halt
52
+ assert not controller.observe(entropy=0.1, step_index=2).should_halt
53
+ assert controller.observe(entropy=0.1, step_index=3).should_halt
54
+
55
+
56
+ def test_candidate_normalization_rejects_duplicates():
57
+ with pytest.raises(ValueError, match="Duplicate"):
58
+ normalize_candidates(["A", "a"])
59
+
60
+
61
+ def test_yes_no_candidates_include_aliases():
62
+ yes, no = yes_no_candidates()
63
+ assert yes.label == "Yes"
64
+ assert "YES" in yes.aliases
65
+ assert no.label == "No"
66
+
67
+
68
+ def test_multiple_choice_candidates_include_common_forms():
69
+ candidates = multiple_choice_candidates(["A", "B"])
70
+ assert candidates[0] == AnswerCandidate("A", aliases=("a", "(A)", "A.", "A)"))
71
+ assert candidates[1].label == "B"
72
+
73
+
74
+ def test_integer_candidates_are_inclusive():
75
+ candidates = integer_candidates(2, 4)
76
+ assert [candidate.label for candidate in candidates] == ["2", "3", "4"]
77
+
78
+
79
+ def test_numeric_candidates_from_texts_extracts_numbers_and_range():
80
+ candidates = numeric_candidates_from_texts(
81
+ ["The answer is 1,234.5", "Then subtract -7."],
82
+ extra_integers=(0, 2),
83
+ )
84
+ labels = [candidate.label for candidate in candidates]
85
+ assert "-7" in labels
86
+ assert "1234.5" in labels
87
+ assert {"0", "1", "2"}.issubset(labels)
88
+
89
+
90
+ def test_config_validation():
91
+ with pytest.raises(ValueError, match="theta"):
92
+ HaltCoTConfig(theta=-math.inf)