diff --git a/.github/workflows/ci_meson.yml b/.github/workflows/ci_meson.yml index d1e454b..51437e4 100644 --- a/.github/workflows/ci_meson.yml +++ b/.github/workflows/ci_meson.yml @@ -19,6 +19,7 @@ jobs: python-version: '3.x' - run: python -m pip install meson==${{ matrix.meson_version }} ninja - run: meson setup builddir/ + - run: meson compile -C builddir/ - run: meson test -C builddir/ -v - uses: actions/upload-artifact@v4 if: failure() diff --git a/README.md b/README.md index f98f287..93f3143 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Build with Meson: ```bash meson setup builddir +meson compile -C builddir meson test -C builddir ``` diff --git a/meson.build b/meson.build index 5406d93..cc1e15d 100644 --- a/meson.build +++ b/meson.build @@ -8,17 +8,17 @@ cc = meson.get_compiler('c') math_dep = cc.find_library('m', required: false) unity_dep = dependency('unity', required: true) -# Library configuration - +# Sources epsilon_sources = files( + 'src/csv.c', 'src/rng.c', 'src/stats.c', 'src/hash.c', 'src/transform.c' ) - epsilon_headers = files( + 'src/csv.h', 'src/rng.h', 'src/stats.h', 'src/hash.h', @@ -26,7 +26,7 @@ epsilon_headers = files( 'src/pa.h' ) -# Build library +# Library epsilon_lib = library('epsilon', sources: epsilon_sources, dependencies: math_dep, @@ -34,19 +34,17 @@ epsilon_lib = library('epsilon', version: meson.project_version() ) -# Declare dependency for downstream use - +# Declare dependency for downstream use (export include dirs / link) epsilon_dep = declare_dependency( link_with: epsilon_lib, include_directories: include_directories('src'), dependencies: math_dep ) -# Install headers - +# Install public headers install_headers(epsilon_headers, subdir: 'epsilon') -# Examples and tests configuration +# Examples & tools examples = { 'example_hash': 'examples/example_hash.c', 'example_transform': 'examples/example_transform.c', @@ -54,14 +52,7 @@ examples = { 'example_rng': 'examples/example_rng.c' } -tests = { - 'hash_test': 'tests/hash_test.c', - 'rng_test': 'tests/rng_test.c', - 'stats_test': 'tests/stats_test.c', - 'transform_test': 'tests/transform_test.c', -} - -# Build and register examples +# Build and register examples (not installed) foreach name, source : examples exe = executable(name, source, dependencies: epsilon_dep, @@ -70,7 +61,22 @@ foreach name, source : examples test(name, exe) endforeach -# Build and register tests +# Tools (intended for installation) +executable('razor-cli', files('tools/razor/cli.c'), + dependencies: epsilon_dep, + install: true, + install_dir: get_option('bindir') +) + +# Tests +tests = { + 'csv_test': 'tests/csv_test.c', + 'hash_test': 'tests/hash_test.c', + 'rng_test': 'tests/rng_test.c', + 'stats_test': 'tests/stats_test.c', + 'transform_test': 'tests/transform_test.c', +} + foreach name, source : tests exe = executable(name, source, dependencies: [epsilon_dep, unity_dep], @@ -79,11 +85,10 @@ foreach name, source : tests test(name, exe) endforeach -# Documentation installation +# Docs & packaging install_data( files('README.md', 'LICENSE'), install_dir: 'share/doc/epsilon' ) -# Distribution metadata meson.add_dist_script('echo', 'Package contact: boris@cortext.nl') diff --git a/src/csv.c b/src/csv.c new file mode 100644 index 0000000..ea4229a --- /dev/null +++ b/src/csv.c @@ -0,0 +1,139 @@ +#include "csv.h" +#include +#include +#include +#include + +#include +#include +#include + +typedef enum { START, IN_FIELD, IN_QUOTE } state_t; + +// Parse a single CSV field into buf; returns pointer after field. +const char *csv_parse_field(const char *r, char *buf, size_t buf_size) { + state_t state = START; + size_t w = 0; // write position in buf + + while (*r) { + char c = *r++; + switch (state) { + case START: + if (c == '"') + state = IN_QUOTE; + else if (c == ',' || c == '\n' || c == '\r') + goto done; // empty field + else { + if (w < buf_size - 1) + buf[w++] = c; + state = IN_FIELD; + } + break; + + case IN_FIELD: + if (c == ',' || c == '\n' || c == '\r') + goto done; + if (w < buf_size - 1) + buf[w++] = c; + break; + + case IN_QUOTE: + if (c == '"') { + if (*r == '"') { + if (w < buf_size - 1) + buf[w++] = '"'; + } else { + state = IN_FIELD; + continue; + } + } else { + if (w < buf_size - 1) + buf[w++] = c; + } + break; + } + } + +done: + if (w < buf_size) + buf[w] = '\0'; + else + buf[buf_size - 1] = '\0'; + return r; +} + +// Parse a CSV row into row->fields; returns number of fields +size_t csv_parse_row(const char *r, csv_row_t *row) { + row->n_fields = 0; + char *w = row->buf; // write pointer into buffer + size_t buf_remaining = CSV_BUF_SIZE; + + while (*r && row->n_fields < CSV_MAX_FIELDS) { + row->fields[row->n_fields++] = w; + + r = csv_parse_field(r, w, buf_remaining); + + // advance write pointer past written field + size_t field_len = strlen(w) + 1; + w += field_len; + if (field_len >= buf_remaining) + buf_remaining = 0; + else + buf_remaining -= field_len; + + // move to next field or end of row + if (*r == ',') + r++; + else if (*r == '\r' && r[1] == '\n') { + r += 2; + break; + } else if (*r == '\n' || *r == '\r') { + r++; + break; + } else + break; // end of buffer + } + + return row->n_fields; +} + +// Initialize CSV reader +int csv_reader_open(csv_reader_t *r, const char *path) { + r->f = fopen(path, "r"); + return r->f != NULL ? 0 : -1; +} + +// Read next row; returns 1 on success, 0 on EOF +int csv_reader_next(csv_reader_t *r, csv_row_t *row) { + if (!r->f) + return 0; + char *p = r->buf; + size_t len = 0; + int in_quotes = 0; + + while (fgets(p, CSV_BUF_SIZE - len, r->f)) { + len += strlen(p); + // Count quotes to see if row is complete + for (char *q = p; *q; q++) { + if (*q == '"') + in_quotes = !in_quotes; + } + if (!in_quotes) + break; // complete row + p = r->buf + len; // append next fgets + if (len >= CSV_BUF_SIZE - 1) + break; // prevent overflow + } + + if (len == 0) + return false; // EOF + + csv_parse_row(r->buf, row); + return true; +} + +// Close CSV reader +void csv_reader_close(csv_reader_t *r) { + if (r->f) + fclose(r->f); +} diff --git a/src/csv.h b/src/csv.h new file mode 100644 index 0000000..eaffe1e --- /dev/null +++ b/src/csv.h @@ -0,0 +1,26 @@ +#ifndef CSV_H +#define CSV_H + +#include +#include +#include + +#define CSV_MAX_FIELDS 512 +#define CSV_BUF_SIZE 4096 + +typedef struct { + char *fields[CSV_MAX_FIELDS]; + char buf[CSV_BUF_SIZE]; + size_t n_fields; +} csv_row_t; + +typedef struct { + FILE *f; + char buf[CSV_BUF_SIZE]; +} csv_reader_t; + +int csv_reader_open(csv_reader_t *r, const char *path); +void csv_reader_close(csv_reader_t *r); +int csv_reader_next(csv_reader_t *r, csv_row_t *row); + +#endif // CSV_H diff --git a/tests/csv_test.c b/tests/csv_test.c new file mode 100644 index 0000000..a1af04b --- /dev/null +++ b/tests/csv_test.c @@ -0,0 +1,111 @@ +#include "csv.h" +#include +#include + +// const char *csv_parse_field(const char *r, char *buf, size_t offset); +ssize_t csv_parse_field(const char **in, char **out, size_t *rem) { + ssize_t n; + for (n = 0; **in && **in != ',' && **in != '\n' && **in != '\r'; n++) { + if (*rem <= 1) + return -1; + *(*out)++ = *(*in)++; + (*rem)--; + } + // Add null terminator + *(*out)++ = '\0'; + (*rem)--; + return n; +} + +void test_field_basic(void) { + const char *input = "field1,..."; + size_t buf_remaining = 80; + char buf[buf_remaining]; + char *w = buf; + + ssize_t bytes_parsed = csv_parse_field(&input, &w, &buf_remaining); + TEST_ASSERT_EQUAL(bytes_parsed, strlen("field1")); + TEST_ASSERT_EQUAL_STRING("field1", buf); + TEST_ASSERT_EQUAL(buf_remaining, 80 - strlen("field1") - 1); + TEST_ASSERT_EQUAL(buf + 80, w + buf_remaining); +} + +/* + +void test_csv_parse_field(void) { + struct { + const char *input, *expected; + ptrdiff_t offset; + } tests[] = { + {"field1", "field1", strlen("field1")}, + {"field1,field2", "field1", strlen("field1")}, + {",field2", "", 1}, + {",,field2", "", 1}, + + }; + for (size_t i = 0; i < sizeof(tests) / sizeof(tests[0]); i++) { + char buf[80]; + const char *delim = csv_parse_field(tests[i].input, buf, sizeof(buf)); + printf("Input: '%s' => Parsed: '%s'\n", tests[i].input, buf); + TEST_ASSERT_EQUAL_STRING(tests[i].expected, buf); + TEST_ASSERT_EQUAL(tests[i].offset, delim - tests[i].input); + } +} + +size_t csv_parse_row(char *buf, csv_row_t *row); + +void test_row_single(void) { + csv_row_t row; + char line[] = "singlefield"; + TEST_ASSERT_EQUAL(1, csv_parse_row(line, &row)); + TEST_ASSERT_EQUAL(1, row.n_fields); + TEST_ASSERT_EQUAL_STRING("singlefield", row.fields[0]); +} + +void test_row_basic(void) { + csv_row_t row; + char line[] = "field1,field2,field3"; + TEST_ASSERT_EQUAL(3, csv_parse_row(line, &row)); + TEST_ASSERT_EQUAL(3, row.n_fields); + TEST_ASSERT_EQUAL_STRING("field1", row.fields[0]); + TEST_ASSERT_EQUAL_STRING("field2", row.fields[1]); + TEST_ASSERT_EQUAL_STRING("field3", row.fields[2]); +} + +void test_row_newline_at_end(void) { + csv_row_t row; + char line[] = "field1,field2,field3\n"; + TEST_ASSERT_EQUAL(3, csv_parse_row(line, &row)); + TEST_ASSERT_EQUAL(3, row.n_fields); + TEST_ASSERT_EQUAL_STRING("field1", row.fields[0]); + TEST_ASSERT_EQUAL_STRING("field2", row.fields[1]); + TEST_ASSERT_EQUAL_STRING("field3", row.fields[2]); +} + +void test_row_empty_fields(void) { + csv_row_t row; + char line[] = "field1,,field3,"; + TEST_ASSERT_EQUAL(4, csv_parse_row(line, &row)); + TEST_ASSERT_EQUAL(4, row.n_fields); + TEST_ASSERT_EQUAL_STRING("field1", row.fields[0]); + TEST_ASSERT_EQUAL_STRING("", row.fields[1]); + TEST_ASSERT_EQUAL_STRING("field3", row.fields[2]); + TEST_ASSERT_EQUAL_STRING("", row.fields[3]); +} +*/ +void setUp(void) {} +void tearDown(void) {} +int main(void) { + UNITY_BEGIN(); + RUN_TEST(test_field_basic); + + /* + RUN_TEST(test_csv_parse_field); + RUN_TEST(test_row_single); + RUN_TEST(test_row_basic); + RUN_TEST(test_row_newline_at_end); + RUN_TEST(test_row_empty_fields); + */ + + return UNITY_END(); +} diff --git a/tools/razor/cli.c b/tools/razor/cli.c new file mode 100644 index 0000000..417e4a6 --- /dev/null +++ b/tools/razor/cli.c @@ -0,0 +1,198 @@ +#include "csv.h" +#include +#include +#include +#include +#include + +#define MAX_PATH 4096 +typedef struct { + // Data options + char data_path[MAX_PATH]; + char test_path[MAX_PATH]; + const char *format; + + // Transformation options + const char *target_column; + const char *predictors; + // char *interactions[64]; + // size_t n_interactions; + + // Model options + char load_model[MAX_PATH]; + char save_model[MAX_PATH]; + + // Optimization options + const char *loss; + double lr; + double l1; + double l2; + + // Miscellaneous + int verbose; +} razor_options_t; + +void help(razor_options_t const *options) { + printf("Usage: razor-cli -d FILE [options]\n" + "\n" + "Data:\n" + " -d, --data FILE Input data path\n" + " -t, --test FILE Test data path\n" + " -f, --format FORMAT File format (default: %s)\n" + "\n", + "csv"); + + printf("Transformations:\n" + " -y, --target COL Target column (default: %s)\n" + " -x, --predictors SET Predictor columns (default: all)\n" + " -i, --interactions SET:SET Add pairwise interaction terms\n\n" + " SET = all | none | COL | WILDCARD | COL[,COL...]\n" + " COL = exact column name\n" + " WILDCARD = shell-style pattern (*, ?)\n" + "\n", + options->target_column); + + printf("Model:\n" + " -L, --load FILE Load initial model\n" + " -S, --save FILE Save trained model\n" + "\n"); + + printf("Optimization:\n" + " -r, --lr LR Learning rate (default: %.4g)\n" + " -l, --loss LOSS Loss function (default: %s)\n" + " --l1 VALUE L1 regularization (default: %.4g)\n" + " --l2 VALUE L2 regularization (default: %.4g)\n" + "\n", + options->lr, options->loss, options->l1, options->l2); + + printf("Miscellaneous:\n" + " -v, --verbose Verbose output\n" + " -h, --help Show this help\n" + "\n"); +} + +enum { CLI_OK = 0, CLI_HELP = 1, CLI_ERROR = 2 }; +int parse_options(int argc, char **argv, razor_options_t *options) { + // Initialize default options. + *options = (razor_options_t){.format = "csv", + .target_column = "target", + .loss = "MSE", + .lr = 0.01, + .l1 = 1e-2, + .l2 = 1e-3, + .verbose = 0}; + + static struct option long_options[] = { + // Data options. + {"data", required_argument, 0, 'd'}, + {"test", required_argument, 0, 't'}, + {"format", required_argument, 0, 'f'}, + + // Transformation options. + {"target", required_argument, 0, 'y'}, + {"predictors", required_argument, 0, 'x'}, + {"interactions", required_argument, 0, 'i'}, + + // Model options. + {"load", required_argument, 0, 'L'}, + {"save", required_argument, 0, 'S'}, + + // Optimization options. + {"lr", required_argument, 0, 'r'}, + {"loss", required_argument, 0, 'l'}, + {"l1", required_argument, 0, 0}, + {"l2", required_argument, 0, 0}, + + // Miscellaneous. + {"verbose", no_argument, 0, 'v'}, + {"help", no_argument, 0, 'h'}, + {0, 0, 0, 0}}; + + int opt, long_index = 0; + while ((opt = getopt_long(argc, argv, "d:t:f:y:x:i:L:S:r:l:vh", + long_options, &long_index)) != -1) { + switch (opt) { + case 'd': + strncpy(options->data_path, optarg, MAX_PATH); + break; + case 't': + strncpy(options->test_path, optarg, MAX_PATH); + break; + case 'f': + options->format = optarg; + break; + case 'L': + strncpy(options->load_model, optarg, MAX_PATH); + break; + case 'S': + strncpy(options->save_model, optarg, MAX_PATH); + break; + case 'y': + options->target_column = optarg; + break; + case 'i': + abort(); + break; + case 'l': + options->loss = optarg; + break; + case 'r': + options->lr = atof(optarg); + break; + case 'v': + options->verbose = 1; + break; + case 'h': + return CLI_HELP; + case 0: + if (strcmp(long_options[long_index].name, "l1") == 0) + options->l1 = atof(optarg); + if (strcmp(long_options[long_index].name, "l2") == 0) + options->l2 = atof(optarg); + break; + default: + return CLI_ERROR; + } + } + + if (!strlen(options->data_path)) { + fprintf(stderr, "Input file is required.\n"); + return CLI_ERROR; + } + + return CLI_OK; +} + +int main(int argc, char **argv) { + razor_options_t options = {0}; + switch (parse_options(argc, argv, &options)) { + case CLI_OK: + break; + case CLI_HELP: + help(&options); + exit(0); + case CLI_ERROR: + default: + help(&options); + exit(1); + } + + // Read data file. + csv_reader_t reader; + csv_row_t row; + if (csv_reader_open(&reader, options.data_path) != 0) { + perror("Cannot open data file"); + exit(1); + } + + while (csv_reader_next(&reader, &row)) { + for (size_t i = 0; i < row.n_fields; i++) + printf("%s|", row.fields[i]); + printf("\n"); + break; + } + + csv_reader_close(&reader); + + return 0; +}