From 3186e151e35b31fee43726433a5cae0e011c4038 Mon Sep 17 00:00:00 2001 From: nataliemes Date: Wed, 26 Aug 2026 09:05:49 +0400 Subject: [PATCH 1/6] Pass 'decimals' as an argument --- src/lemke/bimatrix.py | 29 ++++++++++++++--------------- src/lemke/lemke.py | 21 +++++++++++++++------ src/lemke/utils.py | 33 +++++++++++++++------------------ tests/test_bimatrix_units.py | 2 -- 4 files changed, 44 insertions(+), 41 deletions(-) diff --git a/src/lemke/bimatrix.py b/src/lemke/bimatrix.py index 268ce7b..973a5cc 100644 --- a/src/lemke/bimatrix.py +++ b/src/lemke/bimatrix.py @@ -9,7 +9,6 @@ from . import columnprint, lemke, randomstart, utils from .randomstart import MAX_ACCURACY -from .utils import MAXDECIMALS # file format: # @@ -52,7 +51,7 @@ def __init__(self, A): self.matrix = np.zeros((m, n), dtype=fractions.Fraction) for i in range(m): for j in range(n): - self.matrix[i][j] = utils.tofraction(AA[i][j]) + self.matrix[i][j] = utils.tofraction(AA[i][j], utils.DEFAULT_DECIMALS) self.fullmaxmin() def __str__(self): @@ -101,7 +100,8 @@ def __init__(self, A, B): # create A,B from file @classmethod - def from_file(cls, filename): + def from_file(cls, filename, decimals=utils.DEFAULT_DECIMALS): + utils.validate_decimals(decimals) lines = utils.stripcomments(filename) # flatten into words words = utils.towords(lines) @@ -113,10 +113,10 @@ def from_file(cls, filename): print("m=", m, ", n=", n, ", need", needfracs, "payoffs, got", len(words) - 2) exit(1) k = 2 - C = utils.tomatrix(m, n, words, k) + C = utils.tomatrix(m, n, words, k, decimals) A = payoffmatrix(C) k += m * n - C = utils.tomatrix(m, n, words, k) + C = utils.tomatrix(m, n, words, k, decimals) B = payoffmatrix(C) return cls(A, B) @@ -300,9 +300,9 @@ def common_options(f): ) @click.option( "--decimals", - default=4, + default=utils.DEFAULT_DECIMALS, show_default=True, - type=click.IntRange(min=0, max=MAXDECIMALS), + type=click.IntRange(min=0, max=utils.MAXDECIMALS), metavar="INTEGER", help="Allowed payoff digits in input after decimal point", ) @@ -312,8 +312,7 @@ def common_options(f): # help="Show value of z0 at each step", # ) @wraps(f) - def wrapper(*args, decimals, **kwargs): - utils.setdecimals(decimals) + def wrapper(*args, **kwargs): return f(*args, **kwargs) return wrapper @@ -326,10 +325,10 @@ def wrapper(*args, decimals, **kwargs): help="Missing labels, e.g. 1,3-5,7- " "[default: all labels]", ) -def lh(filename, labels): +def lh(filename, decimals, labels): """Find equilibria using the Lemke-Howson algorithm.""" - G = bimatrix.from_file(filename) + G = bimatrix.from_file(filename, decimals) G.LH(labels) @@ -341,10 +340,10 @@ def trace(): @trace.command(name="uniform") @common_options -def trace_uniform_cmd(filename): +def trace_uniform_cmd(filename, decimals): """Trace using a uniform prior.""" - G = bimatrix.from_file(filename) + G = bimatrix.from_file(filename, decimals) G.trace_uniform_prior() @@ -371,8 +370,8 @@ def trace_uniform_cmd(filename): metavar="INTEGER", help="Denominator x: each coordinate of the prior is rounded to the nearest 1/x", ) -def trace_random_cmd(filename, priors, seed, accuracy): +def trace_random_cmd(filename, decimals, priors, seed, accuracy): """Trace using random prior(s).""" - G = bimatrix.from_file(filename) + G = bimatrix.from_file(filename, decimals) G.trace_random_priors(priors, seed, accuracy) diff --git a/src/lemke/lemke.py b/src/lemke/lemke.py index 5735c3d..4fdca2c 100644 --- a/src/lemke/lemke.py +++ b/src/lemke/lemke.py @@ -18,7 +18,8 @@ def __init__(self, M, q, d): self.n = len(d) @classmethod - def from_file(cls, filename): + def from_file(cls, filename, decimals=utils.DEFAULT_DECIMALS): + utils.validate_decimals(decimals) # create LCP from file lines = utils.stripcomments(filename) # flatten into words @@ -42,15 +43,15 @@ def from_file(cls, filename): while k < len(words): if words[k] == "M=": k += 1 - M = utils.tomatrix(n, n, words, k) + M = utils.tomatrix(n, n, words, k, decimals) k += n * n elif words[k] == "q=": k += 1 - q = utils.tovector(n, words, k) + q = utils.tovector(n, words, k, decimals) k += n elif words[k] == "d=": k += 1 - d = utils.tovector(n, words, k) + d = utils.tovector(n, words, k, decimals) k += n else: raise ValueError( @@ -557,18 +558,26 @@ def runlemke(*, lcp, callback=None): is_flag=True, help="Show value of z0 at each step", ) +@click.option( + "--decimals", + default=utils.DEFAULT_DECIMALS, + show_default=True, + type=click.IntRange(min=0, max=utils.MAXDECIMALS), + metavar="INTEGER", + help="Allowed payoff digits in input after decimal point", +) @click.argument( "lcpfilename", type=click.Path(exists=True, readable=True, file_okay=True, dir_okay=False), ) -def main(verbose, z0, lcpfilename): +def main(verbose, z0, decimals, lcpfilename): """ Tool for solving linear complementarity problems using Lemke's algorithm. LCPFILENAME is the path to the input file. """ - m = lcp.from_file(lcpfilename) + m = lcp.from_file(lcpfilename, decimals) result = runlemke( lcp=m, diff --git a/src/lemke/utils.py b/src/lemke/utils.py index cc4adf9..c0db068 100644 --- a/src/lemke/utils.py +++ b/src/lemke/utils.py @@ -1,5 +1,4 @@ # file utilities -# tofraction utilities with global decimals import fractions @@ -7,24 +6,21 @@ # global constants, mutable # https://stackoverflow.com/questions/1977362/how-to-create-module-wide-variables-in-python -decimals = 4 -deciDenom = 10 ** decimals +DEFAULT_DECIMALS = 4 MAXDECIMALS = 20 # roundingwarn = False -def setdecimals(n): - global decimals, deciDenom - if 0 <= n <= MAXDECIMALS: - decimals = n - deciDenom = 10 ** decimals - else: - # if roundingwarn: - print(n, "as number of decimals not in allowed range 0 to", MAXDECIMALS) - return +commentchars = "#%*" # lines starting with these are ignored -commentchars = "#%*" # lines starting with these are ignored +def validate_decimals(decimals): + if not isinstance(decimals, int): + raise TypeError("decimals must be an integer") + if decimals < 0 or decimals > MAXDECIMALS: + raise ValueError( + f"{decimals} as number of decimals not in allowed range 0 to {MAXDECIMALS}" + ) # read file into list of line-strings @@ -56,7 +52,8 @@ def towords(lines): # convert s to fraction # if s contains ".": convert to decimal fraction # (numerator deciDenom) -def tofraction(s): +def tofraction(s, decimals): + deciDenom = 10 ** decimals if isinstance(s, str) and "." in s: s = float(s) if isinstance(s, float): @@ -69,19 +66,19 @@ def tofraction(s): # create n-vector of fractions from words[start,start+n) -def tovector(n, words, start): +def tovector(n, words, start, decimals): vector = np.zeros(n, dtype=fractions.Fraction) for i in range(n): - vector[i] = tofraction(words[start + i]) + vector[i] = tofraction(words[start + i], decimals) return vector # create (m,n)-matrix of fractions from words[start,start+m*n) -def tomatrix(m, n, words, start): +def tomatrix(m, n, words, start, decimals): C = np.zeros((m, n), dtype=fractions.Fraction) k = start for i in range(m): for j in range(n): - C[i][j] = tofraction(words[k]) + C[i][j] = tofraction(words[k], decimals) k += 1 return C diff --git a/tests/test_bimatrix_units.py b/tests/test_bimatrix_units.py index 130573e..186607d 100644 --- a/tests/test_bimatrix_units.py +++ b/tests/test_bimatrix_units.py @@ -4,7 +4,6 @@ import pytest from click.testing import CliRunner -from lemke import utils from lemke.bimatrix import ( bimatrix, lh, @@ -83,7 +82,6 @@ def test_addcolumn_updates_shape_max_min(small_payoff_matrix): # --- BIMATRIX INIT -------------------------------------------------- @pytest.fixture def small_game_file(tmp_path): - utils.setdecimals(4) content = textwrap.dedent(""" 2 2 From fc1ce83a30048e698e98137022e3d1125bde228d Mon Sep 17 00:00:00 2001 From: nataliemes Date: Wed, 26 Aug 2026 09:18:09 +0400 Subject: [PATCH 2/6] Accept only fractions in payoffmatrix init --- src/lemke/bimatrix.py | 9 ++++----- tests/test_bimatrix_units.py | 4 ++-- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/lemke/bimatrix.py b/src/lemke/bimatrix.py index 973a5cc..efb3c51 100644 --- a/src/lemke/bimatrix.py +++ b/src/lemke/bimatrix.py @@ -44,14 +44,13 @@ def rangesplit(s, endrange=50): class payoffmatrix: # create matrix from any numerical matrix def __init__(self, A): - AA = np.array(A) + AA = np.array(A, dtype=object) + if not all(isinstance(x, fractions.Fraction) for x in AA.flat): + raise TypeError("matrix must contain only Fraction values") m, n = AA.shape self.numrows = m self.numcolumns = n - self.matrix = np.zeros((m, n), dtype=fractions.Fraction) - for i in range(m): - for j in range(n): - self.matrix[i][j] = utils.tofraction(AA[i][j], utils.DEFAULT_DECIMALS) + self.matrix = AA self.fullmaxmin() def __str__(self): diff --git a/tests/test_bimatrix_units.py b/tests/test_bimatrix_units.py index 186607d..cf0f750 100644 --- a/tests/test_bimatrix_units.py +++ b/tests/test_bimatrix_units.py @@ -23,8 +23,8 @@ @pytest.fixture def small_payoff_matrix(): return payoffmatrix([ - [1, 2], - [3, 4], + [Fraction(1), Fraction(2)], + [Fraction(3), Fraction(4)], ]) From 19f5168851eac5e664da635509a861714c5c7181 Mon Sep 17 00:00:00 2001 From: nataliemes Date: Wed, 26 Aug 2026 09:23:53 +0400 Subject: [PATCH 3/6] Update string to fraction conversion --- src/lemke/utils.py | 44 ++++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/src/lemke/utils.py b/src/lemke/utils.py index c0db068..6d17e17 100644 --- a/src/lemke/utils.py +++ b/src/lemke/utils.py @@ -1,6 +1,11 @@ # file utilities import fractions +from decimal import ( + ROUND_HALF_UP, + Decimal, + InvalidOperation, +) import numpy as np @@ -49,20 +54,31 @@ def towords(lines): return words -# convert s to fraction -# if s contains ".": convert to decimal fraction -# (numerator deciDenom) -def tofraction(s, decimals): - deciDenom = 10 ** decimals - if isinstance(s, str) and "." in s: - s = float(s) - if isinstance(s, float): - num = int(abs(s) * deciDenom + 0.5) # round .5 away from zero - if s < 0: - num = -num - return fractions.Fraction(num, deciDenom) - # any other s than a float or string containing '.': - return fractions.Fraction(s) +def tofraction(s: str, decimals: int) -> fractions.Fraction: + """Convert a string to an exact Fraction. + + If `s` contains '.', it's treated as a decimal literal and + rounded to `decimals` places (half away from zero). + Otherwise, it's parsed as an integer or 'p/q' fraction string. + """ + if not isinstance(s, str): + raise TypeError( + f"to_fraction expects a string, got {type(s).__name__}: {s!r}" + ) + + if "." in s: + try: + d = Decimal(s) + except InvalidOperation as e: + raise ValueError(f"{s!r} is not a valid decimal number") from e + denominator = 10 ** decimals + scaled = (d * denominator).to_integral_value(rounding=ROUND_HALF_UP) + return fractions.Fraction(int(scaled), denominator) + + try: + return fractions.Fraction(s) + except (ValueError, ZeroDivisionError) as e: + raise ValueError(f"{s!r} is not a valid number or fraction: {e}") from e # create n-vector of fractions from words[start,start+n) From c14af005b0c533767e32d32c72636d07a5f09184 Mon Sep 17 00:00:00 2001 From: nataliemes Date: Wed, 26 Aug 2026 09:27:51 +0400 Subject: [PATCH 4/6] Add a test for rounding edge cases --- tests/test_lcp_units.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/test_lcp_units.py b/tests/test_lcp_units.py index 4d6bd3c..4513e6b 100644 --- a/tests/test_lcp_units.py +++ b/tests/test_lcp_units.py @@ -35,6 +35,24 @@ def test_lcp_valid_file(tmp_path, raw, expected): assert m.M[0][0] == expected +@pytest.mark.parametrize( + "raw, expected, decimals", + [ + ("0.00015", Fraction(2, 10000), 4), + ("0.145", Fraction(3, 20), 2), + ("9999999999999991.1", Fraction(9999999999999991, 1), 0), + ("9999999999999999.1", Fraction(99999999999999991, 10), 4), + ] +) +def test_lcp_file_parsing_in_fractions(tmp_path, raw, expected, decimals): + content = f"n= 1\nM= {raw}\nq= 1\nd= 1\n" + file_path = tmp_path / "lcp" + file_path.write_text(content) + + m = lcp.from_file(str(file_path), decimals) + assert m.M[0][0] == expected + + @pytest.mark.parametrize("content", [ "M= 1 0 0 1 q= 1 1 d= 1 1\n", # missing n= "n= 2\nM= 1 0 0 1\nq= 1 1\nd= 1\n", # wrong number of values From a18f6f7fce5ef2fdf1b80c66de6a05da5daebbe5 Mon Sep 17 00:00:00 2001 From: nataliemes Date: Fri, 4 Sep 2026 13:24:16 +0400 Subject: [PATCH 5/6] Fix error message --- src/lemke/utils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lemke/utils.py b/src/lemke/utils.py index 6d17e17..394da0b 100644 --- a/src/lemke/utils.py +++ b/src/lemke/utils.py @@ -63,7 +63,7 @@ def tofraction(s: str, decimals: int) -> fractions.Fraction: """ if not isinstance(s, str): raise TypeError( - f"to_fraction expects a string, got {type(s).__name__}: {s!r}" + f"tofraction expects a string, got {type(s).__name__}: {s!r}" ) if "." in s: From 7468fe1e8c3eefa4644c8855e26566aa06946db3 Mon Sep 17 00:00:00 2001 From: nataliemes Date: Fri, 4 Sep 2026 13:29:51 +0400 Subject: [PATCH 6/6] Accept only fractions in addrow, addcolumn --- src/lemke/bimatrix.py | 4 ++++ tests/test_bimatrix_units.py | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/lemke/bimatrix.py b/src/lemke/bimatrix.py index efb3c51..20c4a47 100644 --- a/src/lemke/bimatrix.py +++ b/src/lemke/bimatrix.py @@ -81,12 +81,16 @@ def fullmaxmin(self): # add full row, row must be of size n def addrow(self, row): + if not all(isinstance(x, fractions.Fraction) for x in row): + raise TypeError("New row must contain only Fraction values") self.matrix = np.vstack([self.matrix, row]) self.numrows += 1 self.updatemaxmin(self.numrows - 1, 0) # add full column, col must be of size m def addcolumn(self, col): + if not all(isinstance(x, fractions.Fraction) for x in col): + raise TypeError("New column must contain only Fraction values") self.matrix = np.column_stack([self.matrix, col]) self.numcolumns += 1 self.updatemaxmin(0, self.numcolumns - 1) diff --git a/tests/test_bimatrix_units.py b/tests/test_bimatrix_units.py index cf0f750..d17c4fb 100644 --- a/tests/test_bimatrix_units.py +++ b/tests/test_bimatrix_units.py @@ -63,7 +63,7 @@ def test_payoff_matrix_negshift_negmatrix(small_payoff_matrix): def test_addrow_updates_shape_max_min(small_payoff_matrix): pm = small_payoff_matrix - pm.addrow([10, -10]) + pm.addrow([Fraction(10), Fraction(-10)]) assert pm.numrows == 3 assert pm.matrix[2][0] == Fraction(10) assert pm.max == Fraction(10) @@ -72,7 +72,7 @@ def test_addrow_updates_shape_max_min(small_payoff_matrix): def test_addcolumn_updates_shape_max_min(small_payoff_matrix): pm = small_payoff_matrix - pm.addcolumn([-5, 20]) + pm.addcolumn([Fraction(-5), Fraction(20)]) assert pm.numcolumns == 3 assert pm.matrix[0][2] == Fraction(-5) assert pm.max == Fraction(20)