mostlyai-engine 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. mostlyai/engine/__init__.py +27 -0
  2. mostlyai/engine/_common.py +612 -0
  3. mostlyai/engine/_dtypes.py +58 -0
  4. mostlyai/engine/_encoding_types/__init__.py +13 -0
  5. mostlyai/engine/_encoding_types/language/__init__.py +13 -0
  6. mostlyai/engine/_encoding_types/language/text.py +41 -0
  7. mostlyai/engine/_encoding_types/tabular/__init__.py +13 -0
  8. mostlyai/engine/_encoding_types/tabular/categorical.py +115 -0
  9. mostlyai/engine/_encoding_types/tabular/character.py +155 -0
  10. mostlyai/engine/_encoding_types/tabular/datetime.py +254 -0
  11. mostlyai/engine/_encoding_types/tabular/itt.py +352 -0
  12. mostlyai/engine/_encoding_types/tabular/lat_long.py +353 -0
  13. mostlyai/engine/_encoding_types/tabular/numeric.py +548 -0
  14. mostlyai/engine/_language/__init__.py +17 -0
  15. mostlyai/engine/_language/common.py +110 -0
  16. mostlyai/engine/_language/encoding.py +162 -0
  17. mostlyai/engine/_language/engine/__init__.py +13 -0
  18. mostlyai/engine/_language/engine/base.py +51 -0
  19. mostlyai/engine/_language/engine/hf_engine.py +136 -0
  20. mostlyai/engine/_language/engine/vllm_engine.py +187 -0
  21. mostlyai/engine/_language/formatron_utils.py +114 -0
  22. mostlyai/engine/_language/generation.py +309 -0
  23. mostlyai/engine/_language/lstm.py +139 -0
  24. mostlyai/engine/_language/tokenizer_utils.py +144 -0
  25. mostlyai/engine/_language/training.py +718 -0
  26. mostlyai/engine/_memory.py +69 -0
  27. mostlyai/engine/_tabular/__init__.py +13 -0
  28. mostlyai/engine/_tabular/argn.py +1382 -0
  29. mostlyai/engine/_tabular/common.py +29 -0
  30. mostlyai/engine/_tabular/encoding.py +406 -0
  31. mostlyai/engine/_tabular/fairness.py +245 -0
  32. mostlyai/engine/_tabular/generation.py +1157 -0
  33. mostlyai/engine/_tabular/training.py +856 -0
  34. mostlyai/engine/_training_utils.py +159 -0
  35. mostlyai/engine/_workspace.py +225 -0
  36. mostlyai/engine/analysis.py +571 -0
  37. mostlyai/engine/domain.py +171 -0
  38. mostlyai/engine/encoding.py +42 -0
  39. mostlyai/engine/generation.py +110 -0
  40. mostlyai/engine/splitting.py +257 -0
  41. mostlyai/engine/training.py +104 -0
  42. mostlyai_engine-1.0.0.dist-info/METADATA +179 -0
  43. mostlyai_engine-1.0.0.dist-info/RECORD +45 -0
  44. mostlyai_engine-1.0.0.dist-info/WHEEL +4 -0
  45. mostlyai_engine-1.0.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,162 @@
1
+ # Copyright 2025 MOSTLY AI
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import json
16
+ import logging
17
+ import time
18
+ import warnings
19
+ from pathlib import Path
20
+
21
+ import pandas as pd
22
+ from pandas.core.dtypes.common import is_datetime64_any_dtype
23
+ from tokenizers.pre_tokenizers import ByteLevel
24
+
25
+ from mostlyai.engine._common import is_sequential, ProgressCallback, ProgressCallbackWrapper, TABLE_COLUMN_INFIX
26
+ from mostlyai.engine._workspace import ensure_workspace_dir, Workspace, reset_dir
27
+
28
+ _LOG = logging.getLogger(__name__)
29
+
30
+
31
+ def format_df(df: pd.DataFrame, columns: list[str], is_target: bool = False) -> pd.DataFrame:
32
+ df = df[columns].copy()
33
+ # Some columns (e.g., SCP columns) may contain np.ndarray, which are not JSON serializable
34
+ # We need to drop them before converting the DataFrame to JSON
35
+ sequential_columns = [col for col in df.columns if is_sequential(df[col])]
36
+ df = df.drop(columns=sequential_columns)
37
+ _LOG.info(f"Formatting {'target' if is_target else 'context'} columns {df.columns.tolist()} to JSON")
38
+ # convert date format to ISO so that it's JSON serializable
39
+ for col in df.columns:
40
+ if is_datetime64_any_dtype(df[col]):
41
+ df[col] = df[col].dt.strftime("%Y-%m-%dT%H:%M:%S")
42
+ return df.apply(lambda x: row_to_json(x, is_target=is_target), axis=1)
43
+
44
+
45
+ def fallback_serializer(obj):
46
+ warnings.warn(f"{type(obj)} is not JSON serializable. Converting it to str(obj) instead.")
47
+ return str(obj)
48
+
49
+
50
+ def row_to_json(row: pd.Series, is_target: bool = False) -> str:
51
+ row_dict = row.to_dict()
52
+ nested_dict = {}
53
+ for key, value in row_dict.items():
54
+ tokens = key.split(TABLE_COLUMN_INFIX, maxsplit=1)
55
+ if len(tokens) == 1:
56
+ table_name = "tgt" # assign a default table name for the target table
57
+ column_name = tokens[0] # columns from the target table
58
+ else: # len(tokens) == 2
59
+ table_name, column_name = tokens # prefixed columns from the context tables
60
+ if table_name not in nested_dict:
61
+ nested_dict[table_name] = {}
62
+ nested_dict[table_name][column_name] = value
63
+ if is_target:
64
+ # assume there is at most one item in nested_dict when is_target=True
65
+ nested_dict = next(iter(nested_dict.values()), {})
66
+ # The leading space is required to avoid inconsistent tokenization results during training and generation
67
+ # This ensures that '{' of the context and target json are tokenized into the same thing
68
+ return " " + json.dumps(
69
+ nested_dict,
70
+ ensure_ascii=False,
71
+ separators=None,
72
+ indent=None,
73
+ default=fallback_serializer,
74
+ )
75
+
76
+
77
+ def encode_df(
78
+ ctx_df: pd.DataFrame,
79
+ ctx_columns: list[str],
80
+ tgt_df: pd.DataFrame | None = None,
81
+ tgt_columns: list[str] | None = None,
82
+ ) -> pd.DataFrame:
83
+ assert (tgt_df is None) == (tgt_columns is None), "tgt_df and tgt_columns must be both None or both not None"
84
+ df = pd.DataFrame()
85
+ df["ctx"] = format_df(ctx_df, columns=ctx_columns, is_target=False)
86
+ if tgt_df is not None and tgt_columns is not None:
87
+ df["tgt"] = format_df(tgt_df, columns=tgt_columns, is_target=True)
88
+
89
+ # log the bounds of n_tokens in this partition
90
+ content = df["ctx"] + df["tgt"] if "tgt" in df.columns else df["ctx"]
91
+ n_chars_stats = content.apply(lambda x: len(x)).describe(percentiles=[0.5]).rename("#chars")
92
+ pretokenizer = ByteLevel(add_prefix_space=True, use_regex=True)
93
+ n_pretokens_stats = (
94
+ content.apply(lambda x: len(pretokenizer.pre_tokenize_str(x))).describe(percentiles=[0.5]).rename("#pretokens")
95
+ )
96
+ stats = pd.concat([n_pretokens_stats, n_chars_stats], axis=1).loc[["min", "50%", "max"], :]
97
+ _LOG.info(f"token statistics of this partition: \n{stats}")
98
+ return df
99
+
100
+
101
+ def _encode_partition(
102
+ *,
103
+ tgt_partition_file: Path,
104
+ tgt_stats: dict,
105
+ output_path: Path,
106
+ ctx_partition_file: Path | None = None,
107
+ ctx_stats: dict | None = None,
108
+ ) -> None:
109
+ tgt_df = pd.read_parquet(tgt_partition_file)
110
+ tgt_columns = list(tgt_stats.get("columns", {}).keys())
111
+ if ctx_partition_file:
112
+ ctx_df = pd.read_parquet(ctx_partition_file)
113
+ ctx_columns = list(ctx_stats.get("columns", {}).keys())
114
+ else:
115
+ # create on-the-fly context
116
+ ctx_df = pd.DataFrame(index=range(len(tgt_df)))
117
+ ctx_columns = []
118
+ df = encode_df(
119
+ ctx_df=ctx_df,
120
+ ctx_columns=ctx_columns,
121
+ tgt_df=tgt_df,
122
+ tgt_columns=tgt_columns,
123
+ )
124
+ # shuffle and persist to disk as parquet files
125
+ df = df.sample(frac=1)
126
+ df.to_parquet(output_path / tgt_partition_file.name, engine="pyarrow", index=False)
127
+ _LOG.info(f"encoded partition {tgt_partition_file.name} {df.shape}")
128
+
129
+
130
+ def encode(workspace_dir: str | Path | None = None, update_progress: ProgressCallback | None = None) -> None:
131
+ _LOG.info("ENCODE_LANGUAGE started")
132
+ t0 = time.time()
133
+ with ProgressCallbackWrapper(update_progress) as progress:
134
+ workspace_dir = ensure_workspace_dir(workspace_dir)
135
+ workspace = Workspace(workspace_dir)
136
+ reset_dir(workspace.encoded_data_val.path)
137
+ reset_dir(workspace.encoded_data_trn.path)
138
+ reset_dir(workspace.encoded_data_path)
139
+ has_context = workspace.ctx_data_path.exists()
140
+
141
+ tgt_stats = workspace.tgt_stats.read()
142
+ tgt_pqt_partitions = workspace.tgt_data.fetch_all()
143
+
144
+ if has_context:
145
+ ctx_stats = workspace.ctx_stats.read()
146
+ ctx_pqt_partitions = workspace.ctx_data.fetch_all()
147
+ if len(tgt_pqt_partitions) != len(ctx_pqt_partitions):
148
+ raise RuntimeError("partition files for tgt and ctx do not match")
149
+ else:
150
+ ctx_stats = None
151
+ ctx_pqt_partitions = None
152
+
153
+ for i in range(len(tgt_pqt_partitions)):
154
+ _encode_partition(
155
+ tgt_partition_file=tgt_pqt_partitions[i],
156
+ tgt_stats=tgt_stats,
157
+ output_path=workspace.encoded_data_path,
158
+ ctx_partition_file=ctx_pqt_partitions[i] if has_context else None,
159
+ ctx_stats=ctx_stats if has_context else None,
160
+ )
161
+ progress.update(completed=i, total=len(tgt_pqt_partitions) + 1)
162
+ _LOG.info(f"ENCODE_LANGUAGE finished in {time.time() - t0:.2f}s")
@@ -0,0 +1,13 @@
1
+ # Copyright 2025 MOSTLY AI
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
@@ -0,0 +1,51 @@
1
+ # Copyright 2025 MOSTLY AI
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from dataclasses import dataclass
16
+ from abc import abstractmethod, ABC
17
+ from collections.abc import Callable
18
+
19
+ from formatron.formatter import FormatterBuilder
20
+
21
+
22
+ @dataclass
23
+ class EngineMetrics:
24
+ tokenize_time: float
25
+ generate_time: float
26
+
27
+
28
+ class LanguageEngine(ABC):
29
+ @abstractmethod
30
+ def initialize_logits_processors(
31
+ self, formatter_builders: list[FormatterBuilder], vocab_processors: list[Callable] | None = None
32
+ ):
33
+ pass
34
+
35
+ @abstractmethod
36
+ def generate(
37
+ self, text: list[str], sampling_temperature: float, sampling_top_p: float
38
+ ) -> tuple[list[int], EngineMetrics]:
39
+ pass
40
+
41
+ @abstractmethod
42
+ def get_default_batch_size(self) -> int:
43
+ pass
44
+
45
+ @abstractmethod
46
+ def supports_json_enforcing(self) -> bool:
47
+ pass
48
+
49
+ @abstractmethod
50
+ def cleanup(self):
51
+ pass
@@ -0,0 +1,136 @@
1
+ # Copyright 2025 MOSTLY AI
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from os import PathLike
16
+ import time
17
+ from collections.abc import Callable
18
+ from pathlib import Path
19
+
20
+ import torch
21
+ from formatron.integrations.transformers import create_formatter_logits_processor_list
22
+ from peft import PeftModel
23
+
24
+ from transformers import AutoTokenizer
25
+ from mostlyai.engine._language.common import load_base_model_and_config
26
+ from mostlyai.engine._language.tokenizer_utils import tokenize_fn
27
+ from mostlyai.engine._language.formatron_utils import monkey_patch_formatron
28
+
29
+ from mostlyai.engine._language.engine.base import EngineMetrics, LanguageEngine
30
+ from formatron.formatter import FormatterBuilder
31
+
32
+
33
+ class HuggingFaceEngine(LanguageEngine):
34
+ def __init__(
35
+ self, model_path: PathLike | str, device: torch.device, max_new_tokens: int, tokenizer_max_length: int
36
+ ):
37
+ self.device = device
38
+ self.max_new_tokens = max_new_tokens
39
+ self.tokenizer_max_length = tokenizer_max_length
40
+
41
+ is_peft_adapter = (Path(model_path) / "adapter_config.json").exists()
42
+ model_path = str(model_path)
43
+ self._model, _ = load_base_model_and_config(
44
+ model_path, device=device, is_peft_adapter=is_peft_adapter, is_training=False
45
+ )
46
+ if is_peft_adapter:
47
+ self._model = PeftModel.from_pretrained(self._model, model_path, is_trainable=False)
48
+ self._model = self._model.merge_and_unload()
49
+ self._default_batch_size = 64
50
+ else:
51
+ # only the LSTM model does not have an adapter
52
+ self._default_batch_size = 128
53
+
54
+ self.tokenizer = AutoTokenizer.from_pretrained(
55
+ model_path,
56
+ padding_side="left",
57
+ truncation_side="left",
58
+ legacy=True,
59
+ # these must be False at initialization, as we manually add them later in tokenize_fn
60
+ add_bos_token=False,
61
+ add_eos_token=False,
62
+ )
63
+
64
+ # we can't enforce JSON output if LSTM tokenizer training was skipped
65
+ is_trained_lstm_tokenizer = not is_peft_adapter and self.tokenizer.vocab_size > len(
66
+ self.tokenizer.special_tokens_map
67
+ )
68
+ self._json_enforcing_possible = is_peft_adapter or is_trained_lstm_tokenizer
69
+
70
+ # apply all necessary monkey patches to the formatron library
71
+ if self._json_enforcing_possible:
72
+ monkey_patch_formatron()
73
+
74
+ self._logits_processors = None
75
+
76
+ def get_default_batch_size(self) -> int:
77
+ return self._default_batch_size
78
+
79
+ def supports_json_enforcing(self) -> bool:
80
+ return self._json_enforcing_possible
81
+
82
+ def initialize_logits_processors(
83
+ self, formatter_builders: list[FormatterBuilder], vocab_processors: list[Callable] | None = None
84
+ ):
85
+ self._logits_processors = create_formatter_logits_processor_list(
86
+ tokenizer=self.tokenizer, formatter_builders=formatter_builders, vocab_processors=vocab_processors
87
+ )
88
+
89
+ def generate(
90
+ self, text: list[str], sampling_temperature: float, sampling_top_p: float
91
+ ) -> tuple[list[int], EngineMetrics]:
92
+ do_sample = sampling_temperature > 0.0
93
+
94
+ tokenize_kwargs = dict(
95
+ tokenizer=self.tokenizer,
96
+ return_tensors="pt",
97
+ add_bos_token=True,
98
+ add_eos_token=False,
99
+ padding=True,
100
+ truncation=True,
101
+ max_length=self.tokenizer_max_length, # truncates input
102
+ )
103
+ t_tokenize = time.time()
104
+ inputs = tokenize_fn(text=text, **tokenize_kwargs).to(self.device)
105
+ tokenize_time = time.time() - t_tokenize
106
+
107
+ generate_kwargs = dict(
108
+ do_sample=do_sample,
109
+ max_new_tokens=self.max_new_tokens,
110
+ temperature=sampling_temperature if do_sample else None,
111
+ top_p=sampling_top_p if do_sample else None,
112
+ bos_token_id=self.tokenizer.bos_token_id,
113
+ pad_token_id=self.tokenizer.eos_token_id, # must be eos or will get ValueError from formatron
114
+ eos_token_id=self.tokenizer.eos_token_id,
115
+ )
116
+
117
+ if self._logits_processors is not None:
118
+ # number of formatters must match the batch size, batch size is always reduced so this is fine
119
+ actual_batch_size = len(inputs["input_ids"])
120
+ self._logits_processors[0]._formatters = self._logits_processors[0]._formatters[:actual_batch_size]
121
+
122
+ t_generate = time.time()
123
+ outputs = self._model.generate(**inputs, **generate_kwargs, logits_processor=self._logits_processors)
124
+ generate_time = time.time() - t_generate
125
+
126
+ if self._logits_processors:
127
+ self._logits_processors[0].reset()
128
+
129
+ _, input_length = inputs["input_ids"].shape
130
+ # truncate the prompt from the outputs
131
+ outputs = outputs[:, input_length:]
132
+ metrics = EngineMetrics(tokenize_time=tokenize_time, generate_time=generate_time)
133
+ return outputs.detach().cpu().tolist(), metrics
134
+
135
+ def cleanup(self):
136
+ pass
@@ -0,0 +1,187 @@
1
+ # Copyright 2025 MOSTLY AI
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import contextlib
16
+ from os import PathLike
17
+ import time
18
+ import typing
19
+ from mostlyai.engine._language.engine.base import EngineMetrics, LanguageEngine
20
+ import torch
21
+ from formatron.config import EngineGenerationConfig
22
+ from formatron.formatter import FormatterBuilder
23
+
24
+ from peft import PeftConfig
25
+
26
+ from transformers import AutoTokenizer, AutoConfig, PreTrainedTokenizerBase
27
+ from vllm import LLM, SamplingParams
28
+ from vllm.lora.request import LoRARequest
29
+ from vllm.config import _get_and_verify_max_len
30
+ from mostlyai.engine._language.tokenizer_utils import tokenize_fn
31
+ from formatron.integrations.vllm import create_engine_vocabulary, FormattersLogitsProcessor
32
+ from vllm.distributed import destroy_model_parallel, destroy_distributed_environment
33
+
34
+ import gc
35
+ from vllm.platforms import current_platform
36
+
37
+
38
+ def cleanup_dist_env_and_memory():
39
+ """Copy from current main of vllm replace by import when possible"""
40
+ destroy_model_parallel()
41
+ destroy_distributed_environment()
42
+ with contextlib.suppress(AssertionError):
43
+ torch.distributed.destroy_process_group()
44
+ gc.collect()
45
+ if not current_platform.is_cpu():
46
+ torch.cuda.empty_cache()
47
+
48
+
49
+ def create_vllm_logits_processors(
50
+ llm: LLM,
51
+ formatter_builders: typing.Sequence[FormatterBuilder | None],
52
+ configs: typing.Sequence[EngineGenerationConfig] = None,
53
+ vocab_processors: list[typing.Callable] | None = None,
54
+ ) -> list[FormattersLogitsProcessor]:
55
+ """
56
+ Create list of formatter logits processors without creating engine vocabulary several times.
57
+ """
58
+ tokenizer = llm.get_tokenizer()
59
+ vocab = create_engine_vocabulary(tokenizer, vocab_processors)
60
+ formatters = [
61
+ i.build(vocab, lambda tokens: tokenizer.decode(tokens)) if i is not None else None for i in formatter_builders
62
+ ]
63
+ return [FormattersLogitsProcessor([formatter], tokenizer.eos_token_id, configs) for formatter in formatters]
64
+
65
+
66
+ class MaskInvalidIndicesLogitsProcessor:
67
+ """
68
+ Certain models have output size greater than their vocabulary size.
69
+ This logits processor masks the output indices that do not correspond
70
+ to a token id.
71
+ """
72
+
73
+ def __init__(self, tokenizer: PreTrainedTokenizerBase): # : PretrainedTokenizer
74
+ self.mask: torch.Tensor | None = None
75
+ self.valid_token_ids = torch.tensor(list(tokenizer.vocab.values()))
76
+
77
+ def __call__(self, input_ids: list[int], scores: torch.Tensor) -> torch.Tensor:
78
+ if self.mask is None:
79
+ ninf = float("-inf")
80
+ self.mask = torch.full_like(scores, ninf)
81
+ self.mask[..., self.valid_token_ids] = 0
82
+ scores = scores + self.mask
83
+ return scores
84
+
85
+
86
+ class VLLMEngine(LanguageEngine):
87
+ def __init__(
88
+ self, model_path: PathLike | str, device: torch.device, max_new_tokens: int, tokenizer_max_length: int
89
+ ):
90
+ self.device = device
91
+ self.tokenizer_max_length = tokenizer_max_length
92
+ self.max_new_tokens = max_new_tokens
93
+
94
+ peft_config = PeftConfig.from_pretrained(model_path)
95
+ base_config = AutoConfig.from_pretrained(peft_config.base_model_name_or_path)
96
+
97
+ model_path = str(model_path)
98
+ self._lora_request = LoRARequest("adapter", 1, model_path)
99
+ config_max_model_len = _get_and_verify_max_len(
100
+ base_config, max_model_len=None, disable_sliding_window=None, sliding_window_len=None
101
+ )
102
+ self.llm = LLM(
103
+ model=peft_config.base_model_name_or_path,
104
+ tokenizer=model_path,
105
+ device=device.type,
106
+ max_model_len=min(config_max_model_len, self.tokenizer_max_length + max_new_tokens),
107
+ enable_lora=True,
108
+ # enforce_eager=True, # results in big slowdown, but is needed when running pytest locally
109
+ swap_space=0,
110
+ )
111
+ self._base_logits_processors = [MaskInvalidIndicesLogitsProcessor(self.llm.get_tokenizer())]
112
+ self.tokenizer = AutoTokenizer.from_pretrained(
113
+ model_path,
114
+ padding_side="left",
115
+ truncation_side="left",
116
+ legacy=True,
117
+ # these must be False at initialization, as we manually add them later in tokenize_fn
118
+ add_bos_token=False,
119
+ add_eos_token=False,
120
+ )
121
+ self._logits_processors = None
122
+
123
+ def get_default_batch_size(self) -> int:
124
+ return 192
125
+
126
+ def supports_json_enforcing(self) -> bool:
127
+ return True
128
+
129
+ def initialize_logits_processors(
130
+ self, formatter_builders: list[FormatterBuilder], vocab_processors: list[typing.Callable] | None = None
131
+ ):
132
+ self._logits_processors = create_vllm_logits_processors(
133
+ llm=self.llm, formatter_builders=formatter_builders, configs=None, vocab_processors=vocab_processors
134
+ )
135
+
136
+ def generate(
137
+ self, text: list[str], sampling_temperature: float, sampling_top_p: float
138
+ ) -> tuple[list[int], EngineMetrics]:
139
+ tokenize_kwargs = dict(
140
+ tokenizer=self.tokenizer,
141
+ return_tensors=None,
142
+ add_bos_token=True,
143
+ add_eos_token=False,
144
+ padding=False,
145
+ truncation=True,
146
+ max_length=self.tokenizer_max_length, # truncates input
147
+ )
148
+ t_tokenize = time.time()
149
+ inputs = tokenize_fn(text=text, **tokenize_kwargs)
150
+ tokenize_time = time.time() - t_tokenize
151
+
152
+ actual_batch_size = len(inputs["input_ids"])
153
+ if self._logits_processors is not None:
154
+ sampling_params = [
155
+ SamplingParams(
156
+ max_tokens=self.max_new_tokens,
157
+ temperature=sampling_temperature,
158
+ top_p=sampling_top_p,
159
+ logits_processors=[lp, *self._base_logits_processors],
160
+ )
161
+ for lp in self._logits_processors[:actual_batch_size]
162
+ ]
163
+ else:
164
+ sampling_params = SamplingParams(
165
+ max_tokens=self.max_new_tokens,
166
+ temperature=sampling_temperature,
167
+ top_p=sampling_top_p,
168
+ logits_processors=self._base_logits_processors,
169
+ )
170
+ t_generate = time.time()
171
+ outputs = self.llm.generate(
172
+ prompts=None,
173
+ prompt_token_ids=inputs["input_ids"],
174
+ sampling_params=sampling_params,
175
+ use_tqdm=False,
176
+ lora_request=self._lora_request,
177
+ )
178
+ generate_time = time.time() - t_generate
179
+ if self._logits_processors is not None:
180
+ for lp in self._logits_processors:
181
+ lp.reset()
182
+ metrics = EngineMetrics(tokenize_time=tokenize_time, generate_time=generate_time)
183
+ return [r.outputs[0].token_ids for r in outputs], metrics
184
+
185
+ def cleanup(self):
186
+ del self.llm
187
+ cleanup_dist_env_and_memory()
@@ -0,0 +1,114 @@
1
+ # Copyright 2025 MOSTLY AI
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import typing
16
+
17
+ import pandas as pd
18
+ from formatron.schemas.pydantic import ClassSchema
19
+ from json import JSONDecodeError
20
+ from pydantic import ValidationError
21
+ from formatron.formatter import FormatterBuilder
22
+ from typing import Literal
23
+ from formatron.formats import json
24
+ from pydantic import create_model
25
+ from transformers import PreTrainedTokenizerBase
26
+
27
+ JSON_NULL = "null"
28
+
29
+
30
+ def prepare_seed_for_formatron(sample_seed: pd.DataFrame, tokenizer: PreTrainedTokenizerBase) -> pd.DataFrame:
31
+ def transform(x: str | None) -> str:
32
+ if pd.isna(x):
33
+ null = tokenizer.decode(tokenizer.encode(JSON_NULL), skip_special_tokens=True)
34
+ # formatron needs to be able to express JSON_NULL with available vocabulary
35
+ # if that's the case, harmonize null-like values to None (e.g. pd.NA would cause formatron to fail)
36
+ # otherwise, fallback to empty string
37
+ return None if null == JSON_NULL else ""
38
+ # skip tokens unseen during training
39
+ return tokenizer.decode(tokenizer.encode(x), skip_special_tokens=True)
40
+
41
+ return sample_seed.astype("string[pyarrow]").map(transform)
42
+
43
+
44
+ def monkey_patch_formatron():
45
+ # alter the Grammar of formatron's json schema
46
+ FORMATRON_WHITESPACE_MAX_REPETITIONS = 10
47
+ SPACE_NONTERMINAL = f"[ \t\n\r]{{0,{FORMATRON_WHITESPACE_MAX_REPETITIONS}}}"
48
+
49
+ json.GRAMMAR_HEADER = rf"""integer ::= #"-?(0|[1-9]\\d*)";
50
+ number ::= #"-?(0|[1-9]\\d*)(\\.\\d+)?([eE][+-]?\\d+)?";
51
+ string ::= #'"([^\\\\"\u0000-\u001f]|\\\\["\\\\bfnrt/]|\\\\u[0-9A-Fa-f]{{4}})*"';
52
+ boolean ::= "true"|"false";
53
+ null ::= "null";
54
+ array ::= array_begin (json_value (comma json_value)*)? array_end;
55
+ object ::= object_begin (string colon json_value (comma string colon json_value)*)? object_end;
56
+ json_value ::= number|string|boolean|null|array|object;
57
+ comma ::= #"{SPACE_NONTERMINAL},{SPACE_NONTERMINAL}";
58
+ colon ::= #"{SPACE_NONTERMINAL}:{SPACE_NONTERMINAL}";
59
+ object_begin ::= #" \\{{{SPACE_NONTERMINAL}";
60
+ object_end ::= #"{SPACE_NONTERMINAL}\\}}";
61
+ array_begin ::= #"\\[{SPACE_NONTERMINAL}";
62
+ array_end ::= #"{SPACE_NONTERMINAL}\\]";
63
+ """
64
+
65
+
66
+ def get_formatter_builders(
67
+ *, seed_df: pd.DataFrame | None = None, size: int | None = None, unseeded_fields: list[str]
68
+ ) -> list[FormatterBuilder]:
69
+ assert (seed_df is not None) ^ (size is not None), "exactly one of seed_df or size must be provided"
70
+ formatter_builders = []
71
+ if seed_df is None:
72
+ seed_df = pd.DataFrame(index=range(size))
73
+ for _, seed_row in seed_df.iterrows():
74
+ formatter_builder = FormatterBuilder()
75
+ model_dict = {}
76
+ if not seed_row.empty:
77
+ model_dict |= {field_name: (Literal[seed_value], ...) for field_name, seed_value in seed_row.items()}
78
+ model_dict |= {field_name: (str, ...) for field_name in unseeded_fields}
79
+ schema = create_model("TargetModel", **model_dict, __base__=MostlyClassSchema)
80
+ formatter_builder.append_str(f"{formatter_builder.json(schema, capture_name=None)}")
81
+ formatter_builders.append(formatter_builder)
82
+ return formatter_builders
83
+
84
+
85
+ def get_vocab_processors(is_peft_adapter: bool) -> list[typing.Callable] | None:
86
+ if not is_peft_adapter:
87
+
88
+ def update_vocab_lstm(token_to_char: dict[bytes, bytes]):
89
+ """
90
+ Maps special tokens ("▁", "␊") back to their original representation (" ", "\n")
91
+ (used in LSTM tokenizer)
92
+ """
93
+ token_to_char["\u2581".encode()] = b" " # "▁" -> " "
94
+ token_to_char["\u240a".encode()] = b"\n" # "␊" -> "\n"
95
+
96
+ return [update_vocab_lstm]
97
+ return None
98
+
99
+
100
+ class MostlyClassSchema(ClassSchema):
101
+ @classmethod
102
+ def from_json(cls, _json: str) -> "MostlyClassSchema":
103
+ """
104
+ Create a MostlyClassSchema from a JSON string.
105
+ """
106
+ try:
107
+ return cls.model_validate_json(_json)
108
+ except ValidationError as e:
109
+ for error in e.errors():
110
+ if error["type"] == "json_invalid":
111
+ raise JSONDecodeError(
112
+ f"Caught pydantic ValidationError {e}, reraising as JSONDecodeError", _json, 0
113
+ )
114
+ raise e